Browse Source

Merge pull request #25690 from abpframework/issue/ng-v22-upgrade

Angular - Upgrading the version to 22.0.x
issut/7552
Fahri Gedik 3 months ago
committed by GitHub
parent
commit
0602add1a0
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      npm/ng-packs/.gitignore
  2. 2
      npm/ng-packs/.prettierignore
  3. 4
      npm/ng-packs/apps/dev-app/project.json
  4. 7
      npm/ng-packs/apps/dev-app/src/app/app.component.ts
  5. 6
      npm/ng-packs/apps/dev-app/src/app/app.config.ts
  6. 4
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html
  7. 52
      npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts
  8. 2
      npm/ng-packs/apps/dev-app/src/app/home/home.component.html
  9. 7
      npm/ng-packs/apps/dev-app/src/app/home/home.component.ts
  10. 23
      npm/ng-packs/apps/dev-app/src/app/localization-test/localization-test.component.ts
  11. 2
      npm/ng-packs/apps/dev-app/tsconfig.json
  12. 52
      npm/ng-packs/guides/DEVELOPMENT_GUIDE.md
  13. 164
      npm/ng-packs/migrations.json
  14. 10
      npm/ng-packs/nx.json
  15. 55
      npm/ng-packs/package.json
  16. 2
      npm/ng-packs/packages/account-core/project.json
  17. 2
      npm/ng-packs/packages/account/project.json
  18. 33
      npm/ng-packs/packages/account/src/lib/components/change-password/change-password.component.html
  19. 29
      npm/ng-packs/packages/account/src/lib/components/change-password/change-password.component.ts
  20. 16
      npm/ng-packs/packages/account/src/lib/components/forgot-password/forgot-password.component.html
  21. 14
      npm/ng-packs/packages/account/src/lib/components/forgot-password/forgot-password.component.ts
  22. 4
      npm/ng-packs/packages/account/src/lib/components/login/login.component.html
  23. 11
      npm/ng-packs/packages/account/src/lib/components/login/login.component.ts
  24. 20
      npm/ng-packs/packages/account/src/lib/components/manage-profile/manage-profile.component.html
  25. 35
      npm/ng-packs/packages/account/src/lib/components/manage-profile/manage-profile.component.ts
  26. 3
      npm/ng-packs/packages/account/src/lib/components/personal-settings/personal-settings-half-row.component.ts
  27. 12
      npm/ng-packs/packages/account/src/lib/components/personal-settings/personal-settings.component.html
  28. 18
      npm/ng-packs/packages/account/src/lib/components/personal-settings/personal-settings.component.ts
  29. 9
      npm/ng-packs/packages/account/src/lib/components/register/register.component.html
  30. 16
      npm/ng-packs/packages/account/src/lib/components/register/register.component.ts
  31. 78
      npm/ng-packs/packages/account/src/lib/components/reset-password/reset-password.component.html
  32. 41
      npm/ng-packs/packages/account/src/lib/components/reset-password/reset-password.component.ts
  33. 17
      npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.html
  34. 63
      npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.ts
  35. 8
      npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.html
  36. 55
      npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.ts
  37. 12
      npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.html
  38. 55
      npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.ts
  39. 12
      npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.html
  40. 79
      npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.ts
  41. 4
      npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.html
  42. 22
      npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.ts
  43. 2
      npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.html
  44. 8
      npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.ts
  45. 6
      npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.html
  46. 46
      npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.ts
  47. 2
      npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.html
  48. 34
      npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.ts
  49. 4
      npm/ng-packs/packages/cms-kit/admin/src/components/global-resources/global-resources.component.html
  50. 14
      npm/ng-packs/packages/cms-kit/admin/src/components/global-resources/global-resources.component.ts
  51. 18
      npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-list/menu-item-list.component.html
  52. 69
      npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-list/menu-item-list.component.ts
  53. 22
      npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-modal/menu-item-modal.component.html
  54. 85
      npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-modal/menu-item-modal.component.ts
  55. 6
      npm/ng-packs/packages/cms-kit/admin/src/components/pages/page-form/page-form.component.html
  56. 41
      npm/ng-packs/packages/cms-kit/admin/src/components/pages/page-form/page-form.component.ts
  57. 8
      npm/ng-packs/packages/cms-kit/admin/src/components/pages/page-list/page-list.component.html
  58. 51
      npm/ng-packs/packages/cms-kit/admin/src/components/pages/page-list/page-list.component.ts
  59. 8
      npm/ng-packs/packages/cms-kit/admin/src/components/tags/tag-list/tag-list.component.html
  60. 77
      npm/ng-packs/packages/cms-kit/admin/src/components/tags/tag-list/tag-list.component.ts
  61. 2
      npm/ng-packs/packages/cms-kit/admin/src/components/tags/tag-modal/tag-modal.component.html
  62. 11
      npm/ng-packs/packages/cms-kit/admin/src/components/tags/tag-modal/tag-modal.component.ts
  63. 6
      npm/ng-packs/packages/cms-kit/admin/src/tests/blog-post-form.service.spec.ts
  64. 6
      npm/ng-packs/packages/cms-kit/admin/src/tests/page-form.service.spec.ts
  65. 10
      npm/ng-packs/packages/cms-kit/src/components/code-mirror-editor/code-mirror-editor.component.ts
  66. 8
      npm/ng-packs/packages/cms-kit/src/components/toast-ui/toastui-editor.component.ts
  67. 4
      npm/ng-packs/packages/components/chart.js/src/chart.component.ts
  68. 2
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.html
  69. 14
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.ts
  70. 6
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts
  71. 710
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html
  72. 97
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.ts
  73. 20
      npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.ts
  74. 43
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.html
  75. 99
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.ts
  76. 2
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.ts
  77. 3
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table-row-detail/extensible-table-row-detail.component.ts
  78. 45
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html
  79. 49
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts
  80. 2
      npm/ng-packs/packages/components/package.json
  81. 3
      npm/ng-packs/packages/components/page/src/page-parts.component.ts
  82. 11
      npm/ng-packs/packages/components/page/src/page.component.ts
  83. 2
      npm/ng-packs/packages/components/project.json
  84. 57
      npm/ng-packs/packages/components/tree/src/lib/components/tree.component.ts
  85. 4
      npm/ng-packs/packages/components/tsconfig.lib.json
  86. 2
      npm/ng-packs/packages/core/project.json
  87. 3
      npm/ng-packs/packages/core/src/lib/abstracts/ng-model.component.ts
  88. 68
      npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts
  89. 11
      npm/ng-packs/packages/core/src/lib/components/replaceable-route-container.component.ts
  90. 3
      npm/ng-packs/packages/core/src/lib/components/router-outlet.component.ts
  91. 10
      npm/ng-packs/packages/core/src/lib/guards/permission.guard.ts
  92. 4
      npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts
  93. 31
      npm/ng-packs/packages/core/src/lib/strategies/projection.strategy.ts
  94. 4
      npm/ng-packs/packages/core/testing/src/lib/core-testing.module.ts
  95. 2
      npm/ng-packs/packages/feature-management/project.json
  96. 3
      npm/ng-packs/packages/feature-management/src/lib/components/feature-management-tab/feature-management-tab.component.ts
  97. 234
      npm/ng-packs/packages/feature-management/src/lib/components/feature-management/feature-management.component.html
  98. 77
      npm/ng-packs/packages/feature-management/src/lib/components/feature-management/feature-management.component.ts
  99. 10
      npm/ng-packs/packages/feature-management/src/lib/directives/free-text-input.directive.ts
  100. 3
      npm/ng-packs/packages/generators/package.json

3
npm/ng-packs/.gitignore

@ -59,4 +59,5 @@ Thumbs.db
.nx/
vitest.config.*.timestamp*
vitest.config.*.timestamp*
.claude/worktrees

2
npm/ng-packs/.prettierignore

@ -11,3 +11,5 @@
/.nx/cache
/.nx/workspace-data
.angular
.nx/self-healing

4
npm/ng-packs/apps/dev-app/project.json

@ -166,7 +166,7 @@
"defaultConfiguration": "development"
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"executor": "@angular/build:extract-i18n",
"options": {
"buildTarget": "dev-app:build"
}
@ -179,7 +179,7 @@
"outputs": ["{workspaceRoot}/coverage/apps/dev-app"],
"options": {
"passWithNoTests": true,
"reportsDirectory": "../../coverage/apps/dev-app",
"reportsDirectory": "{projectRoot}/../../coverage/apps/dev-app",
"silent": false
}
},

7
npm/ng-packs/apps/dev-app/src/app/app.component.ts

@ -1,12 +1,13 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { LoaderBarComponent } from '@abp/ng.theme.shared';
import { DynamicLayoutComponent } from '@abp/ng.core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-root',
template: `
<abp-loader-bar></abp-loader-bar>
<abp-dynamic-layout></abp-dynamic-layout>
<abp-loader-bar />
<abp-dynamic-layout />
`,
imports: [LoaderBarComponent, DynamicLayoutComponent],
})

6
npm/ng-packs/apps/dev-app/src/app/app.config.ts

@ -4,7 +4,7 @@ import {
provideClientHydration,
withEventReplay,
withHttpTransferCacheOptions,
withIncrementalHydration,
withNoIncrementalHydration,
} from '@angular/platform-browser';
import { appRoutes } from './app.routes';
@ -20,7 +20,6 @@ import { provideIdentityConfig } from '@abp/ng.identity/config';
import { provideTenantManagementConfig } from '@abp/ng.tenant-management/config';
import { provideFeatureManagementConfig } from '@abp/ng.feature-management';
import { provideThemeBasicConfig } from '@abp/ng.theme.basic';
import { provideAnimations } from '@angular/platform-browser/animations';
export const appConfig: ApplicationConfig = {
providers: [
@ -46,12 +45,11 @@ export const appConfig: ApplicationConfig = {
provideFeatureManagementConfig(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideThemeBasicConfig(),
provideAnimations(),
provideRouter(appRoutes),
provideClientHydration(
withEventReplay(),
withHttpTransferCacheOptions({}),
withIncrementalHydration(),
withNoIncrementalHydration(),
),
],
};

4
npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.html

@ -20,9 +20,9 @@
<small>All 16 field types + Nested Forms (Group & Array) with full accessibility support</small>
</div>
<div class="card-body p-4">
@if (formFields.length) {
@if (formFields().length) {
<abp-dynamic-form
[fields]="formFields"
[fields]="formFields()"
[submitButtonText]="'Register'"
[showCancelButton]="true"
(onSubmit)="submit($event)"

52
npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts

@ -1,38 +1,34 @@
import { Component, inject, OnInit, viewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, viewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { DynamicFormComponent, FormFieldConfig } from '@abp/ng.components/dynamic-form';
import { FormConfigService } from './form-config.service';
@Component({
selector: 'app-dynamic-form-page',
templateUrl: './dynamic-form-page.component.html',
imports: [DynamicFormComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-dynamic-form-page',
templateUrl: './dynamic-form-page.component.html',
imports: [DynamicFormComponent],
})
export class DynamicFormPageComponent implements OnInit {
readonly dynamicFormComponent = viewChild(DynamicFormComponent);
protected readonly formConfigService = inject(FormConfigService);
export class DynamicFormPageComponent {
readonly dynamicFormComponent = viewChild(DynamicFormComponent);
protected readonly formConfigService = inject(FormConfigService);
formFields: FormFieldConfig[] = [];
readonly formFields = toSignal(this.formConfigService.getFormConfig(), {
initialValue: [] as FormFieldConfig[],
});
ngOnInit() {
this.formConfigService.getFormConfig().subscribe(config => {
this.formFields = config;
});
}
submit(formData: any) {
console.log('✅ Form Submitted Successfully!', formData);
console.table(formData);
submit(formData: any) {
console.log('✅ Form Submitted Successfully!', formData);
console.table(formData);
// Show success message
alert('✅ Form submitted successfully! Check the console for details.');
// Reset form after submission
this.dynamicFormComponent().resetForm();
}
alert('✅ Form submitted successfully! Check the console for details.');
cancel() {
console.log('❌ Form Cancelled');
alert('Form cancelled');
this.dynamicFormComponent().resetForm();
}
this.dynamicFormComponent()?.resetForm();
}
cancel() {
console.log('❌ Form Cancelled');
alert('Form cancelled');
this.dynamicFormComponent()?.resetForm();
}
}

2
npm/ng-packs/apps/dev-app/src/app/home/home.component.html

@ -16,7 +16,7 @@
<p class="lead px-lg-5 mx-lg-5">{{ '::LongWelcomeMessage' | abpLocalization }}</p>
@if (!hasLoggedIn) {
<abp-button [loading]="loading" (click)="login()" [disabled]="loading" class="px-4 ml-1" role="button"
<abp-button [loading]="loading()" (click)="login()" [disabled]="loading()" class="px-4 ml-1" role="button"
iconClass="fa fa-sign-in">
{{ 'AbpAccount::Login' | abpLocalization }}
</abp-button>

7
npm/ng-packs/apps/dev-app/src/app/home/home.component.ts

@ -1,10 +1,11 @@
import { AuthService, LocalizationPipe } from '@abp/ng.core';
import { Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent, CardBodyComponent, CardComponent } from '@abp/ng.theme.shared';
import { RouterLink } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-home',
templateUrl: './home.component.html',
imports: [
@ -18,14 +19,14 @@ import { RouterLink } from '@angular/router';
})
export class HomeComponent {
protected readonly authService = inject(AuthService);
loading = false;
readonly loading = signal(false);
get hasLoggedIn(): boolean {
return this.authService.isAuthenticated;
}
login() {
this.loading = true;
this.loading.set(true);
this.authService.navigateToLogin();
}
}

23
npm/ng-packs/apps/dev-app/src/app/localization-test/localization-test.component.ts

@ -1,13 +1,13 @@
import { Component, inject, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { LocalizationPipe, UILocalizationService, SessionStateService } from '@abp/ng.core';
import { CommonModule } from '@angular/common';
import { CardComponent, CardBodyComponent } from '@abp/ng.theme.shared';
import { AsyncPipe } from '@angular/common';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-localization-test',
standalone: true,
imports: [CommonModule, LocalizationPipe, CardComponent, CardBodyComponent, AsyncPipe],
imports: [CommonModule, LocalizationPipe, CardComponent, CardBodyComponent],
template: `
<div class="container mt-5">
<h2>Hybrid Localization Test</h2>
@ -22,7 +22,7 @@ import { AsyncPipe } from '@angular/common';
<abp-card cardClass="mt-4">
<abp-card-body>
<h5>UI Localization (from /assets/localization/{{ currentLanguage$ | async }}.json)</h5>
<h5>UI Localization (from /assets/localization/{{ currentLanguage() }}.json)</h5>
<p><strong>MyProjectName::CustomKey:</strong> {{ 'MyProjectName::CustomKey' | abpLocalization }}</p>
<p><strong>MyProjectName::TestMessage:</strong> {{ 'MyProjectName::TestMessage' | abpLocalization }}</p>
</abp-card-body>
@ -39,20 +39,19 @@ import { AsyncPipe } from '@angular/common';
<abp-card cardClass="mt-4">
<abp-card-body>
<h5>Loaded UI Localizations</h5>
<pre>{{ loadedLocalizations | json }}</pre>
<pre>{{ loadedLocalizations() | json }}</pre>
</abp-card-body>
</abp-card>
</div>
`,
})
export class LocalizationTestComponent implements OnInit {
export class LocalizationTestComponent {
private uiLocalizationService = inject(UILocalizationService);
private sessionState = inject(SessionStateService);
loadedLocalizations: any = {};
currentLanguage$ = this.sessionState.getLanguage$();
readonly loadedLocalizations = signal(this.uiLocalizationService.getLoadedLocalizations());
ngOnInit() {
this.loadedLocalizations = this.uiLocalizationService.getLoadedLocalizations();
}
readonly currentLanguage = toSignal(this.sessionState.getLanguage$(), {
initialValue: this.sessionState.getLanguage(),
});
}

2
npm/ng-packs/apps/dev-app/tsconfig.json

@ -28,6 +28,6 @@
"angularCompilerOptions": {
"strictInjectionParameters": false,
"strictInputAccessModifiers": false,
"strictTemplates": false
"strictTemplates": true
}
}

52
npm/ng-packs/guides/DEVELOPMENT_GUIDE.md

@ -222,12 +222,52 @@ Example HTML template
### Component Best Practices
1. **Single Responsibility**: Each component should have one clear purpose
2. **Dependency Injection**: Use constructor injection for services
3. **Lifecycle Management**: Implement `OnInit` and `OnDestroy` when needed
4. **State Management**: Use reactive forms and observables
5. **Error Handling**: Implement proper error boundaries
6. **Accessibility**: Follow ARIA guidelines
7. **Performance**: Use `OnPush` change detection when possible
2. **Dependency Injection**: Use `inject()` for services
3. **Change detection**: Set `changeDetection: ChangeDetectionStrategy.OnPush` explicitly on every component
4. **Async state**: Use `signal()` / `computed()` / `toSignal()` — avoid mutating plain properties in `.subscribe()` without a CD trigger
5. **List pages**: Use `toSignal(this.list.hookToQuery(...))` and bind `data().items` in templates
6. **Component IO**: Prefer signal `input()` / `output()` / `model()` over `@Input` / `@Output`
7. **Modal bindings**: `[visible]="isModalVisible()" (visibleChange)="isModalVisible.set($event)"` with `[busy]="modalBusy()"`
8. **`markForCheck()`**: Discouraged — use only in directives, CVAs, or dynamic component hosts where signals cannot apply
9. **Error Handling**: Implement proper error boundaries
10. **Accessibility**: Follow ARIA guidelines
See [upgrade-notes.md](../upgrade-notes.md) for Angular 22 customer migration guidance.
### Angular 22 component pattern (recommended)
```typescript
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ListService, PagedResultDto } from '@abp/ng.core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-my-list',
providers: [ListService],
})
export class MyListComponent {
protected readonly list = inject(ListService);
protected readonly service = inject(MyService);
readonly data = toSignal(this.list.hookToQuery(q => this.service.getList(q)), {
initialValue: { items: [], totalCount: 0 } as PagedResultDto<MyDto>,
});
readonly isModalVisible = signal(false);
readonly modalBusy = signal(false);
}
```
```html
<abp-extensible-table [data]="data().items" [recordsTotal]="data().totalCount" [list]="list" />
<abp-modal
[visible]="isModalVisible()"
(visibleChange)="isModalVisible.set($event)"
[busy]="modalBusy()"
/>
```
## Service Layer

164
npm/ng-packs/migrations.json

@ -1,118 +1,130 @@
{
"migrations": [
{
"version": "22.0.0-beta.1",
"description": "Updates release version config based on the breaking changes in Nx v22",
"implementation": "./src/migrations/update-22-0-0/release-version-config-changes",
"cli": "nx",
"version": "22.6.0-beta.10",
"description": "Adds .claude/worktrees to .gitignore",
"implementation": "./dist/src/migrations/update-22-6-0/add-claude-worktrees-to-git-ignore",
"package": "nx",
"name": "22-0-0-release-version-config-changes"
"name": "22-6-1-add-claude-worktrees-to-git-ignore"
},
{
"version": "22.0.0-beta.2",
"description": "Consolidates releaseTag* options into nested releaseTag object structure",
"implementation": "./src/migrations/update-22-0-0/consolidate-release-tag-config",
"cli": "nx",
"version": "22.7.0-beta.0",
"description": "Adds .nx/polygraph to .gitignore",
"implementation": "./dist/src/migrations/update-22-7-0/add-polygraph-to-git-ignore",
"package": "nx",
"name": "22-0-0-consolidate-release-tag-config"
"name": "22-7-0-add-polygraph-to-git-ignore"
},
{
"cli": "nx",
"version": "22.1.0-beta.5",
"description": "Updates the nx wrapper.",
"implementation": "./src/migrations/update-22-1-0/update-nx-wrapper",
"version": "22.6.0-rc.0",
"description": "Adds .claude/settings.local.json to .gitignore",
"implementation": "./dist/src/migrations/update-17-3-0/update-nxw",
"package": "nx",
"name": "22-1-0-update-nx-wrapper"
"name": "22-6-0-add-claude-settings-local-to-git-ignore"
},
{
"version": "21.5.0-beta.2",
"description": "Migrate the legacy 'development' custom condition to a workspace-unique custom condition name.",
"factory": "./src/migrations/update-21-5-0/migrate-development-custom-condition",
"package": "@nx/js",
"name": "migrate-development-custom-condition"
"cli": "nx",
"version": "22.7.0-beta.0",
"description": "Adds .nx/self-healing to .gitignore",
"implementation": "./dist/src/migrations/update-22-2-0/add-self-healing-to-gitignore",
"package": "nx",
"name": "22-7-0-add-self-healing-to-gitignore"
},
{
"version": "22.0.0-beta.0",
"description": "Remove the deprecated `external` and `externalBuildTargets` options from the `@nx/js:swc` and `@nx/js:tsc` executors.",
"factory": "./src/migrations/update-22-0-0/remove-external-options-from-js-executors",
"package": "@nx/js",
"name": "remove-external-options-from-js-executors"
"version": "22.3.2-beta.0",
"requires": {
"@angular/build": ">=21.0.0"
},
"description": "Create AI Instructions to help migrate users workspaces past breaking changes for Vitest 4.",
"implementation": "./src/migrations/update-22-1-0/create-ai-instructions-for-vitest-4",
"package": "@nx/vitest",
"name": "update-22-3-2"
},
{
"version": "22.1.0-rc.1",
"description": "Removes redundant TypeScript project references from project's tsconfig.json files when runtime tsconfig files (e.g., tsconfig.lib.json, tsconfig.app.json) exist.",
"factory": "./src/migrations/update-22-1-0/remove-redundant-ts-project-references",
"package": "@nx/js",
"name": "remove-redundant-ts-project-references"
"version": "22.6.0-beta.11",
"description": "Prefix reportsDirectory with {projectRoot} to maintain correct resolution after workspace-root-relative behavior change.",
"implementation": "./src/migrations/update-22-6-0/prefix-reports-directory-with-project-root",
"package": "@nx/vitest",
"name": "update-22-6-0-prefix-reports-directory"
},
{
"version": "21.3.0-beta.3",
"description": "Rename the CLI option `testPathPattern` to `testPathPatterns`.",
"implementation": "./src/migrations/update-21-3-0/rename-test-path-pattern",
"package": "@nx/jest",
"name": "rename-test-path-pattern"
"version": "22.7.0-beta.12",
"description": "Add missing inputs to @nx/eslint:lint executor target defaults",
"implementation": "./src/migrations/update-21-6-0/update-executor-lint-inputs",
"package": "@nx/eslint",
"name": "update-executor-lint-inputs"
},
{
"version": "22.2.0-beta.2",
"description": "Convert jest.config.ts files from ESM to CJS syntax (export default -> module.exports, import -> require) for projects using CommonJS resolution to ensure correct loading under Node.js type-stripping.",
"implementation": "./src/migrations/update-22-2-0/convert-jest-config-to-cjs",
"version": "22.3.2-beta.0",
"requires": {
"jest": ">=30.0.0"
},
"description": "Replace removed matcher aliases in Jest v30 with their corresponding matcher",
"implementation": "./src/migrations/update-21-3-0/replace-removed-matcher-aliases",
"package": "@nx/jest",
"name": "convert-jest-config-to-cjs"
"name": "replace-removed-matcher-aliases-v22-3"
},
{
"cli": "nx",
"version": "21.3.0-beta.4",
"requires": { "@angular/core": ">=20.1.0" },
"description": "Update the @angular/cli package version to ~20.1.0.",
"factory": "./src/migrations/update-21-3-0/update-angular-cli",
"version": "22.3.0-beta.0",
"requires": {
"@angular/core": ">=21.0.0"
},
"description": "Updates webpack-based SSR configuration to use preserve module format and bundler module resolution.",
"factory": "./src/migrations/update-22-3-0/update-ssr-webpack-config",
"package": "@nx/angular",
"name": "update-angular-cli-version-20-1-0"
"name": "update-ssr-webpack-config-22-2-0"
},
{
"version": "21.5.0-beta.0",
"description": "Set the 'tsConfig' option to build and test targets to help with Angular migration issues.",
"factory": "./src/migrations/update-21-5-0/set-tsconfig-option",
"version": "22.3.0-beta.0",
"requires": {
"@angular/core": ">=21.0.0-rc.3"
},
"description": "Update 'module' to 'preserve' and 'moduleResolution' to 'bundler' in TypeScript configurations for Angular projects.",
"factory": "./src/migrations/update-22-3-0/update-module-resolution",
"package": "@nx/angular",
"name": "set-tsconfig-option"
"name": "update-module-resolution-22-2-0"
},
{
"cli": "nx",
"version": "21.5.0-beta.2",
"requires": { "@angular/core": ">=20.2.0" },
"description": "Update the @angular/cli package version to ~20.2.0.",
"factory": "./src/migrations/update-21-5-0/update-angular-cli",
"version": "22.3.0-beta.0",
"requires": {
"@angular/core": ">=21.0.0"
},
"description": "Updates the 'lib' property in tsconfig files to use 'es2022' or a more modern version.",
"factory": "./src/migrations/update-22-3-0/update-typescript-lib",
"package": "@nx/angular",
"name": "update-angular-cli-version-20-2-0"
"name": "update-typescript-lib-22-2-0"
},
{
"version": "21.5.0-beta.2",
"requires": { "@angular/core": ">=20.2.0" },
"description": "Remove any Karma configuration files that only contain the default content. The default configuration is automatically available without a specific project configurationfile.",
"factory": "./src/migrations/update-21-5-0/remove-default-karma-configuration-files",
"version": "22.3.0-beta.0",
"requires": {
"@angular/core": ">=21.0.0"
},
"description": "Update 'vitest' unit test runner option to 'vitest-analog' in generator defaults.",
"factory": "./src/migrations/update-22-3-0/update-unit-test-runner-option",
"package": "@nx/angular",
"name": "remove-default-karma-configuration-files"
"name": "update-unit-test-runner-option"
},
{
"cli": "nx",
"version": "21.6.1-beta.2",
"requires": { "@angular/core": ">=20.3.0" },
"description": "Update the @angular/cli package version to ~20.3.0.",
"factory": "./src/migrations/update-21-6-1/update-angular-cli",
"version": "22.3.0-beta.3",
"requires": {
"@angular/core": ">=21.0.0"
},
"description": "Set 'isolatedModules' to 'true' in TypeScript test configurations for Angular projects.",
"factory": "./src/migrations/update-22-3-0/set-isolated-modules",
"package": "@nx/angular",
"name": "update-angular-cli-version-20-3-0"
"name": "set-isolated-modules-22-3-0"
},
{
"version": "20.2.0",
"description": "Replaces usages of the deprecated Router.getCurrentNavigation method with the Router.currentNavigation signal",
"factory": "./bundles/router-current-navigation.cjs#migrate",
"optional": true,
"package": "@angular/core",
"name": "router-current-navigation"
},
{
"version": "20.3.0",
"description": "Adds `BootstrapContext` to `bootstrapApplication` calls in `main.server.ts` to support server rendering.",
"factory": "./bundles/add-bootstrap-context-to-server-main.cjs#migrate",
"package": "@angular/core",
"name": "add-bootstrap-context-to-server-main"
"version": "22.3.0-beta.3",
"requires": {
"@angular/core": ">=21.0.0"
},
"description": "Replace 'jest-preset-angular/setup-jest' imports with the new 'setupZoneTestEnv' function.",
"factory": "./src/migrations/update-22-3-0/update-jest-preset-angular-setup",
"package": "@nx/angular",
"name": "update-jest-preset-angular-setup"
}
]
}

10
npm/ng-packs/nx.json

@ -111,7 +111,12 @@
}
},
"@nx/eslint:lint": {
"inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*"
],
"cache": true
},
"@angular/build:application": {
@ -135,5 +140,6 @@
"!{projectRoot}/.eslintrc.json"
]
},
"parallel": 3
"parallel": 3,
"analytics": false
}

55
npm/ng-packs/package.json

@ -32,7 +32,7 @@
"dep-graph": "nx dep-graph",
"help": "nx help",
"build:schematics": "cd scripts && yarn && yarn build:schematics && cd ..",
"dev:schematics": "tsc -p packages/schematics/tsconfig.json -w",
"dev:schematics": "tsc -p packages/schematics/tsconfig.lib.json -w",
"mock:schematics": "cd scripts/mock-schematic && yarn && yarn start",
"debug:schematics": "./node_modules/.bin/nx g ./packages/schematics/src/collection.json:proxy-add --module identity --apiName __default --source __default --target __default --url https://localhost:44305 --serviceType application --entryPoint __default ",
"debug:schematics-dist": "./node_modules/.bin/ng g ./dist/packages/schematics/collection.json:proxy-add --module __default --apiName __default --source __default --target __default --url http://localhost:4300 --service-type application --entryPoint __default",
@ -77,27 +77,27 @@
"@ng-bootstrap/ng-bootstrap": "~20.0.0",
"@ngneat/spectator": "~19.6.2",
"@ngx-validate/core": "^0.2.0",
"@nx/angular": "~22.2.0",
"@nx/cypress": "~22.2.0",
"@nx/devkit": "~22.2.0",
"@nx/eslint": "~22.2.0",
"@nx/eslint-plugin": "~22.2.0",
"@nx/jest": "~22.2.0",
"@nx/js": "22.2.7",
"@nx/plugin": "~22.2.0",
"@nx/vite": "22.2.7",
"@nx/vitest": "22.2.7",
"@nx/web": "~22.2.0",
"@nx/workspace": "~22.2.0",
"@nx/angular": "~22.7.5",
"@nx/cypress": "~22.7.5",
"@nx/devkit": "~22.7.5",
"@nx/eslint": "~22.7.5",
"@nx/eslint-plugin": "~22.7.5",
"@nx/jest": "~22.7.5",
"@nx/js": "~22.7.5",
"@nx/plugin": "~22.7.5",
"@nx/vite": "~22.7.5",
"@nx/vitest": "~22.7.5",
"@nx/web": "~22.7.5",
"@nx/workspace": "~22.7.5",
"@popperjs/core": "~2.11.0",
"@schematics/angular": "~21.2.0",
"@swc-node/register": "1.9.2",
"@swc/cli": "0.6.0",
"@swc/core": "~1.5.7",
"@swc/helpers": "~0.5.11",
"@schematics/angular": "~22.0.0",
"@swc-node/register": "~1.11.1",
"@swc/cli": "~0.7.10",
"@swc/core": "~1.15.8",
"@swc/helpers": "~0.5.23",
"@swimlane/ngx-datatable": "~22.0.0",
"@types/express": "~5.0.0",
"@types/jest": "29.5.14",
"@types/jest": "~30.0.0",
"@types/node": "20.19.9",
"@typescript-eslint/eslint-plugin": "7.16.0",
"@typescript-eslint/parser": "7.16.0",
@ -117,10 +117,10 @@
"eslint-plugin-cypress": "^3.5.0",
"express": "~5.1.0",
"got": "^11.0.0",
"jest": "^29.0.0",
"jest": "~30.4.2",
"jest-canvas-mock": "^2.0.0",
"jest-environment-jsdom": "^29.0.0",
"jest-preset-angular": "14.6.0",
"jest-environment-jsdom": "~30.4.1",
"jest-preset-angular": "~16.0.0",
"jsdom": "~22.1.0",
"jsonc-eslint-parser": "^2.0.0",
"jsonc-parser": "^2.0.0",
@ -128,9 +128,9 @@
"just-compare": "^2.0.0",
"lerna": "^4.0.0",
"lint-staged": "^13.0.0",
"ng-packagr": "~21.2.0",
"ng-packagr": "~22.0.0",
"ng-zorro-antd": "~21.1.0",
"nx": "~22.2.0",
"nx": "~22.7.5",
"postcss": "^8.0.0",
"postcss-import": "14.1.0",
"postcss-preset-env": "7.5.0",
@ -140,15 +140,16 @@
"@toast-ui/editor": "~3.0.0",
"rxjs": "~7.8.0",
"should-quote": "^1.0.0",
"ts-jest": "29.4.6",
"ts-jest": "~29.4.6",
"ts-node": "10.9.1",
"ts-toolbelt": "^9.0.0",
"tslib": "^2.3.0",
"tslint": "~6.1.0",
"typescript": "~5.9.0",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vitest": "^4.0.0",
"zone.js": "~0.15.0"
"zone.js": "~0.15.0",
"jest-util": "~30.4.1"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx,html,css,scss}": [

2
npm/ng-packs/packages/account-core/project.json

@ -31,7 +31,7 @@
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../coverage/packages/account-core"
"reportsDirectory": "{projectRoot}/../../coverage/packages/account-core"
}
}
}

2
npm/ng-packs/packages/account/project.json

@ -31,7 +31,7 @@
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../coverage/packages/account"
"reportsDirectory": "{projectRoot}/../../coverage/packages/account"
}
}
}

33
npm/ng-packs/packages/account/src/lib/components/change-password/change-password.component.html

@ -1,11 +1,17 @@
<form [formGroup]="form" (ngSubmit)="onSubmit()" [mapErrorsFn]="mapErrorsFn" [validateOnSubmit]="true" class="abp-md-form">
@if (!hideCurrentPassword) {
<form
[formGroup]="form"
(ngSubmit)="onSubmit()"
[mapErrorsFn]="mapErrorsFn"
[validateOnSubmit]="true"
class="abp-md-form"
>
@if (!hideCurrentPassword()) {
<div class="mb-3 form-group">
<label for="current-password" class="form-label">{{
'AbpIdentity::DisplayName:CurrentPassword' | abpLocalization
}}</label
><span> * </span
><input
}}</label>
<span> * </span>
<input
type="password"
id="current-password"
class="form-control"
@ -18,9 +24,9 @@
<div class="mb-3 form-group">
<label for="new-password" class="form-label">{{
'AbpIdentity::DisplayName:NewPassword' | abpLocalization
}}</label
><span> * </span
><input
}}</label>
<span> * </span>
<input
type="password"
id="new-password"
class="form-control"
@ -31,9 +37,9 @@
<div class="mb-3 form-group">
<label for="confirm-new-password" class="form-label">{{
'AbpIdentity::DisplayName:NewPasswordConfirm' | abpLocalization
}}</label
><span> * </span
><input
}}</label>
<span> * </span>
<input
type="password"
id="confirm-new-password"
class="form-control"
@ -45,8 +51,9 @@
iconClass="fa fa-check"
buttonClass="btn btn-primary color-white"
buttonType="submit"
[loading]="inProgress"
[loading]="inProgress()"
[disabled]="form?.invalid"
>{{ 'AbpIdentity::Save' | abpLocalization }}</abp-button
>
{{ 'AbpIdentity::Save' | abpLocalization }}
</abp-button>
</form>

29
npm/ng-packs/packages/account/src/lib/components/change-password/change-password.component.ts

@ -1,6 +1,13 @@
import { ProfileService } from '@abp/ng.account.core/proxy';
import { ButtonComponent, getPasswordValidators, ToasterService } from '@abp/ng.theme.shared';
import { Component, Injector, OnInit, inject } from '@angular/core';
import {
Component,
Injector,
OnInit,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import {
ReactiveFormsModule,
UntypedFormBuilder,
@ -18,6 +25,7 @@ const { required } = Validators;
const PASSWORD_FIELDS = ['newPassword', 'repeatNewPassword'];
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-change-password-form',
templateUrl: './change-password.component.html',
exportAs: 'abpChangePasswordForm',
@ -40,9 +48,8 @@ export class ChangePasswordComponent
form!: UntypedFormGroup;
inProgress?: boolean;
hideCurrentPassword?: boolean;
readonly inProgress = signal(false);
readonly hideCurrentPassword = signal(false);
mapErrorsFn: Validation.MapErrorsFn = (errors, groupErrors, control) => {
if (PASSWORD_FIELDS.indexOf(String(control?.name)) < 0) return errors;
@ -51,7 +58,7 @@ export class ChangePasswordComponent
};
ngOnInit(): void {
this.hideCurrentPassword = !this.manageProfileState.getProfile()?.hasPassword;
this.hideCurrentPassword.set(!this.manageProfileState.getProfile()?.hasPassword);
const passwordValidations = getPasswordValidators(this.injector);
@ -76,18 +83,18 @@ export class ChangePasswordComponent
},
);
if (this.hideCurrentPassword) this.form.removeControl('password');
if (this.hideCurrentPassword()) this.form.removeControl('password');
}
onSubmit() {
if (this.form.invalid) return;
this.inProgress = true;
this.inProgress.set(true);
this.profileService
.changePassword({
...(!this.hideCurrentPassword && { currentPassword: this.form.get('password')?.value }),
...(!this.hideCurrentPassword() && { currentPassword: this.form.get('password')?.value }),
newPassword: this.form.get('newPassword')?.value,
})
.pipe(finalize(() => (this.inProgress = false)))
.pipe(finalize(() => this.inProgress.set(false)))
.subscribe({
next: () => {
this.form.reset();
@ -95,8 +102,8 @@ export class ChangePasswordComponent
life: 5000,
});
if (this.hideCurrentPassword) {
this.hideCurrentPassword = false;
if (this.hideCurrentPassword()) {
this.hideCurrentPassword.set(false);
this.form.addControl('password', new UntypedFormControl('', [required]));
}
},

16
npm/ng-packs/packages/account/src/lib/components/forgot-password/forgot-password.component.html

@ -1,28 +1,28 @@
<h4>{{ 'AbpAccount::ForgotPassword' | abpLocalization }}</h4>
@if (!isEmailSent) {
@if (!isEmailSent()) {
<form [formGroup]="form" (ngSubmit)="onSubmit()" [validateOnSubmit]="true">
<p>{{ 'AbpAccount::SendPasswordResetLink_Information' | abpLocalization }}</p>
<div class="mb-3 form-group">
<label for="input-email-address" class="form-label">{{
'AbpAccount::EmailAddress' | abpLocalization
}}</label
><span> * </span>
}}</label>
<span> * </span>
<input type="email" id="input-email-address" class="form-control" formControlName="email" />
</div>
<abp-button
class="d-block"
buttonClass="mt-2 mb-3 btn btn-primary btn-block"
[loading]="inProgress"
[loading]="inProgress()"
buttonType="submit"
[disabled]="form?.invalid"
>
{{ 'AbpAccount::Submit' | abpLocalization }}
</abp-button>
<a routerLink="/account/login"
><i class="fa fa-long-arrow-left me-1" aria-hidden="true"></i
>{{ 'AbpAccount::Login' | abpLocalization }}</a
>
<a routerLink="/account/login">
<i class="fa fa-long-arrow-left me-1" aria-hidden="true"></i>
{{ 'AbpAccount::Login' | abpLocalization }}
</a>
</form>
} @else {
<p>

14
npm/ng-packs/packages/account/src/lib/components/forgot-password/forgot-password.component.ts

@ -1,5 +1,5 @@
import { AccountService } from '@abp/ng.account.core/proxy';
import { Component, inject } from '@angular/core';
import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import {
ReactiveFormsModule,
UntypedFormBuilder,
@ -13,6 +13,7 @@ import { RouterLink } from '@angular/router';
import { NgxValidateCoreModule } from '@ngx-validate/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-forgot-password',
templateUrl: 'forgot-password.component.html',
imports: [
@ -29,9 +30,8 @@ export class ForgotPasswordComponent {
form: UntypedFormGroup;
inProgress?: boolean;
isEmailSent = false;
readonly inProgress = signal(false);
readonly isEmailSent = signal(false);
constructor() {
this.form = this.fb.group({
@ -42,16 +42,16 @@ export class ForgotPasswordComponent {
onSubmit() {
if (this.form.invalid) return;
this.inProgress = true;
this.inProgress.set(true);
this.accountService
.sendPasswordResetCode({
email: this.form.get('email')?.value,
appName: 'Angular',
})
.pipe(finalize(() => (this.inProgress = false)))
.pipe(finalize(() => this.inProgress.set(false)))
.subscribe(() => {
this.isEmailSent = true;
this.isEmailSent.set(true);
});
}
}

4
npm/ng-packs/packages/account/src/lib/components/login/login.component.html

@ -7,7 +7,7 @@
}}</a>
</strong>
}
<form [formGroup]="form" (ngSubmit)="onSubmit()" validateOnSubmit class="mt-4">
<form [formGroup]="form" (ngSubmit)="onSubmit()" [validateOnSubmit]="true" class="mt-4">
<div class="mb-3 form-group">
<label for="login-input-user-name-or-email-address" class="form-label">{{
'AbpAccount::UserNameOrEmailAddress' | abpLocalization
@ -56,7 +56,7 @@
</div>
<abp-button
[loading]="inProgress"
[loading]="inProgress()"
buttonType="submit"
name="Action"
buttonClass="btn-block btn-lg mt-3 btn btn-primary"

11
npm/ng-packs/packages/account/src/lib/components/login/login.component.ts

@ -1,4 +1,4 @@
import { Component, Injector, OnInit, inject } from '@angular/core';
import { Component, Injector, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import {
ReactiveFormsModule,
UntypedFormBuilder,
@ -22,6 +22,7 @@ import { getRedirectUrl } from '../../utils';
const { maxLength, required } = Validators;
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-login',
templateUrl: './login.component.html',
imports: [
@ -42,7 +43,7 @@ export class LoginComponent implements OnInit {
form!: UntypedFormGroup;
inProgress?: boolean;
readonly inProgress = signal(false);
isSelfRegistrationEnabled = true;
@ -71,7 +72,7 @@ export class LoginComponent implements OnInit {
onSubmit() {
if (this.form.invalid) return;
this.inProgress = true;
this.inProgress.set(true);
const { username, password, rememberMe } = this.form.value;
@ -88,9 +89,9 @@ export class LoginComponent implements OnInit {
'',
{ life: 7000 },
);
return throwError(err);
return throwError(() => err);
}),
finalize(() => (this.inProgress = false)),
finalize(() => this.inProgress.set(false)),
)
.subscribe();
}

20
npm/ng-packs/packages/account/src/lib/components/manage-profile/manage-profile.component.html

@ -1,24 +1,24 @@
<div id="AbpContentToolbar"></div>
<div class="card border-0 shadow-sm min-h-400" [abpLoading]="profileLoading()">
<div class="card border-0 shadow-sm min-h-400" [abpLoading]="!profile()?.userName">
<div class="card-body">
<div class="row">
<div class="col-12 col-md-3">
<ul class="nav flex-column nav-pills" id="nav-tab" role="tablist">
@if (!hideChangePasswordTab && profile()) {
<li class="nav-item" (click)="selectedTab = 0">
@if (!hideChangePasswordTab() && profile()) {
<li class="nav-item" (click)="selectedTab.set(0)">
<a
class="nav-link"
[class.active]="selectedTab === 0"
[class.active]="selectedTab() === 0"
role="tab"
href="javascript:void(0)"
>{{ 'AbpUi::ChangePassword' | abpLocalization }}</a
>
</li>
}
<li class="nav-item mb-2" (click)="selectedTab = 1">
<li class="nav-item mb-2" (click)="selectedTab.set(1)">
<a
class="nav-link"
[class.active]="selectedTab === 1"
[class.active]="selectedTab() === 1"
role="tab"
href="javascript:void(0)"
>{{ 'AbpAccount::PersonalSettings' | abpLocalization }}</a
@ -28,7 +28,7 @@
</div>
@if (profile()) {
<div class="col-12 col-md-9">
@if (selectedTab === 0) {
@if (selectedTab() === 0) {
<div class="tab-content fade-in">
<div class="tab-pane active" role="tabpanel">
<h4>
@ -39,11 +39,11 @@
*abpReplaceableTemplate="{
componentKey: changePasswordKey,
}"
></abp-change-password-form>
/>
</div>
</div>
}
@if (selectedTab === 1) {
@if (selectedTab() === 1) {
<div class="tab-content fade-in">
<div class="tab-pane active" role="tabpanel">
<h4>
@ -54,7 +54,7 @@
*abpReplaceableTemplate="{
componentKey: personalSettingsKey,
}"
></abp-personal-settings-form>
/>
</div>
</div>
}

35
npm/ng-packs/packages/account/src/lib/components/manage-profile/manage-profile.component.ts

@ -1,8 +1,7 @@
import { ProfileService } from '@abp/ng.account.core/proxy';
import { LoadingDirective } from '@abp/ng.theme.shared';
import { Component, inject, OnInit, signal } from '@angular/core';
import { Component, inject, OnInit, signal, ChangeDetectionStrategy } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs/operators';
import { eAccountComponents } from '../../enums/components';
import { ManageProfileStateService } from '../../services/manage-profile.state.service';
import { ReactiveFormsModule } from '@angular/forms';
@ -11,6 +10,7 @@ import { PersonalSettingsComponent } from '../personal-settings/personal-setting
import { ChangePasswordComponent } from '../change-password/change-password.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-manage-profile',
templateUrl: './manage-profile.component.html',
styles: [
@ -39,36 +39,29 @@ import { ChangePasswordComponent } from '../change-password/change-password.comp
ChangePasswordComponent,
LocalizationPipe,
ReplaceableTemplateDirective,
LoadingDirective
LoadingDirective,
],
})
export class ManageProfileComponent implements OnInit {
protected profileService = inject(ProfileService);
protected manageProfileState = inject(ManageProfileStateService);
selectedTab = 0;
readonly selectedTab = signal(0);
readonly hideChangePasswordTab = signal(false);
readonly profile = toSignal(this.manageProfileState.getProfile$());
changePasswordKey = eAccountComponents.ChangePassword;
personalSettingsKey = eAccountComponents.PersonalSettings;
profile = toSignal(this.manageProfileState.getProfile$());
profileLoading = signal(false);
hideChangePasswordTab?: boolean;
ngOnInit() {
this.profileLoading.set(true);
this.profileService
.get()
.pipe(finalize(() => this.profileLoading.set(false)))
.subscribe(profile => {
this.manageProfileState.setProfile(profile);
if (profile.isExternal) {
this.hideChangePasswordTab = true;
this.selectedTab = 1;
}
});
this.profileService.get().subscribe(profile => {
this.manageProfileState.setProfile(profile);
if (profile.isExternal) {
this.hideChangePasswordTab.set(true);
this.selectedTab.set(1);
}
});
}
}

3
npm/ng-packs/packages/account/src/lib/components/personal-settings/personal-settings-half-row.component.ts

@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import {
EXTENSIONS_FORM_PROP,
FormProp,
@ -8,6 +8,7 @@ import { ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { LocalizationPipe } from '@abp/ng.core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-personal-settings-half-row',
template: ` <div class="w-50 d-inline">
<label [attr.for]="name" class="form-label">{{ displayName | abpLocalization }} </label>

12
npm/ng-packs/packages/account/src/lib/components/personal-settings/personal-settings.component.html

@ -1,14 +1,14 @@
@if (form) {
<form [formGroup]="form" (ngSubmit)="submit()" validateOnSubmit class="abp-md-form">
<abp-extensible-form [selectedRecord]="selected"></abp-extensible-form>
<form [formGroup]="form" (ngSubmit)="submit()" [validateOnSubmit]="true" class="abp-md-form">
<abp-extensible-form [selectedRecord]="selected" />
<abp-button
buttonType="submit"
iconClass="fa fa-check"
buttonClass="btn btn-primary color-white"
[loading]="inProgress"
>
{{ 'AbpIdentity::Save' | abpLocalization }}</abp-button
[loading]="inProgress()"
>
{{ 'AbpIdentity::Save' | abpLocalization }}
</abp-button>
</form>
}

18
npm/ng-packs/packages/account/src/lib/components/personal-settings/personal-settings.component.ts

@ -5,8 +5,16 @@ import {
ConfirmationService,
ToasterService,
} from '@abp/ng.theme.shared';
import { Component, inject, Injector, OnInit } from '@angular/core';
import {
Component,
inject,
Injector,
OnInit,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { NgxValidateCoreModule } from '@ngx-validate/core';
import { finalize, filter } from 'rxjs/operators';
import { Account } from '../../models/account';
import { ManageProfileStateService } from '../../services/manage-profile.state.service';
@ -19,9 +27,9 @@ import {
generateFormFromProps,
} from '@abp/ng.components/extensible';
import { eAccountComponents } from '../../enums';
import { NgxValidateCoreModule } from '@ngx-validate/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-personal-settings-form',
templateUrl: './personal-settings.component.html',
exportAs: 'abpPersonalSettingsForm',
@ -61,7 +69,7 @@ export class PersonalSettingsComponent
form!: UntypedFormGroup;
inProgress?: boolean;
readonly inProgress = signal(false);
buildForm() {
this.selected = this.manageProfileState.getProfile();
@ -80,10 +88,10 @@ export class PersonalSettingsComponent
if (this.form.invalid) return;
const isLogOutConfirmMessageVisible = this.isLogoutConfirmMessageActive();
const isRefreshTokenExists = this.authService.getRefreshToken();
this.inProgress = true;
this.inProgress.set(true);
this.profileService
.update(this.form.value)
.pipe(finalize(() => (this.inProgress = false)))
.pipe(finalize(() => this.inProgress.set(false)))
.subscribe(profile => {
this.manageProfileState.setProfile(profile);
this.configState.refreshAppState();

9
npm/ng-packs/packages/account/src/lib/components/register/register.component.html

@ -6,12 +6,7 @@
}}</a>
</strong>
@if (isSelfRegistrationEnabled) {
<form
[formGroup]="form"
(ngSubmit)="onSubmit()"
validateOnSubmit
class="mt-4"
>
<form [formGroup]="form" (ngSubmit)="onSubmit()" [validateOnSubmit]="true" class="mt-4">
<div class="mb-3 form-group">
<label for="input-user-name" class="form-label">{{
'AbpAccount::UserName' | abpLocalization
@ -47,7 +42,7 @@
/>
</div>
<abp-button
[loading]="inProgress"
[loading]="inProgress()"
buttonType="submit"
name="Action"
buttonClass="btn-block btn-lg mt-3 btn btn-primary"

16
npm/ng-packs/packages/account/src/lib/components/register/register.component.ts

@ -6,7 +6,14 @@ import {
LocalizationPipe,
} from '@abp/ng.core';
import { ButtonComponent, getPasswordValidators, ToasterService } from '@abp/ng.theme.shared';
import { Component, Injector, OnInit, inject } from '@angular/core';
import {
Component,
Injector,
OnInit,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import {
ReactiveFormsModule,
UntypedFormBuilder,
@ -23,6 +30,7 @@ import { RouterLink } from '@angular/router';
const { maxLength, required, email } = Validators;
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-register',
templateUrl: './register.component.html',
imports: [
@ -44,7 +52,7 @@ export class RegisterComponent implements OnInit {
form!: UntypedFormGroup;
inProgress?: boolean;
readonly inProgress = signal(false);
isSelfRegistrationEnabled = true;
@ -84,7 +92,7 @@ export class RegisterComponent implements OnInit {
onSubmit() {
if (this.form.invalid) return;
this.inProgress = true;
this.inProgress.set(true);
const newUser = {
userName: this.form.get('username')?.value,
@ -114,7 +122,7 @@ export class RegisterComponent implements OnInit {
return throwError(err);
}),
finalize(() => (this.inProgress = false)),
finalize(() => this.inProgress.set(false)),
)
.subscribe();
}

78
npm/ng-packs/packages/account/src/lib/components/reset-password/reset-password.component.html

@ -1,39 +1,51 @@
<h4>{{ 'AbpAccount::ResetPassword' | abpLocalization }}</h4>
@if (!isPasswordReset) {
<form [formGroup]="form" [mapErrorsFn]="mapErrorsFn" (ngSubmit)="onSubmit()" validateOnSubmit>
<p>{{ 'AbpAccount::ResetPassword_Information' | abpLocalization }}</p>
<div class="mb-3 form-group">
<label for="input-password" class="form-label">{{
'AbpAccount::Password' | abpLocalization
}}</label
><span> * </span>
<input type="password" id="input-password" class="form-control" formControlName="password" />
</div>
<div class="mb-3 form-group">
<label for="input-confirm-password" class="form-label">{{
'AbpAccount::ConfirmPassword' | abpLocalization
}}</label
><span> * </span>
<input
type="password"
id="input-confirm-password"
class="form-control"
formControlName="confirmPassword"
/>
</div>
<button class="me-2 btn btn-outline-primary" type="button" routerLink="/account/login">
{{ 'AbpAccount::Cancel' | abpLocalization }}
</button>
<abp-button
buttonType="submit"
buttonClass="me-2 btn btn-primary"
[loading]="inProgress"
(click)="onSubmit()"
@if (!isPasswordReset()) {
@if (form) {
<form
[formGroup]="form"
[mapErrorsFn]="mapErrorsFn"
(ngSubmit)="onSubmit()"
[validateOnSubmit]="true"
>
{{ 'AbpAccount::Submit' | abpLocalization }}
</abp-button>
</form>
<p>{{ 'AbpAccount::ResetPassword_Information' | abpLocalization }}</p>
<div class="mb-3 form-group">
<label for="input-password" class="form-label">{{
'AbpAccount::Password' | abpLocalization
}}</label>
<span> * </span>
<input
type="password"
id="input-password"
class="form-control"
formControlName="password"
/>
</div>
<div class="mb-3 form-group">
<label for="input-confirm-password" class="form-label">{{
'AbpAccount::ConfirmPassword' | abpLocalization
}}</label>
<span> * </span>
<input
type="password"
id="input-confirm-password"
class="form-control"
formControlName="confirmPassword"
/>
</div>
<button class="me-2 btn btn-outline-primary" type="button" routerLink="/account/login">
{{ 'AbpAccount::Cancel' | abpLocalization }}
</button>
<abp-button
buttonType="submit"
buttonClass="me-2 btn btn-primary"
[loading]="inProgress()"
(click)="onSubmit()"
>
{{ 'AbpAccount::Submit' | abpLocalization }}
</abp-button>
</form>
}
} @else {
<p>
{{ 'AbpAccount::YourPasswordIsSuccessfullyReset' | abpLocalization }}

41
npm/ng-packs/packages/account/src/lib/components/reset-password/reset-password.component.ts

@ -1,6 +1,14 @@
import { AccountService } from '@abp/ng.account.core/proxy';
import { ButtonComponent, getPasswordValidators } from '@abp/ng.theme.shared';
import { Component, Injector, OnInit, inject } from '@angular/core';
import {
Component,
effect,
Injector,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
ReactiveFormsModule,
UntypedFormBuilder,
@ -15,6 +23,7 @@ import { LocalizationPipe } from '@abp/ng.core';
const PASSWORD_FIELDS = ['password', 'confirmPassword'];
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-reset-password',
templateUrl: './reset-password.component.html',
imports: [
@ -25,18 +34,19 @@ const PASSWORD_FIELDS = ['password', 'confirmPassword'];
ButtonComponent,
],
})
export class ResetPasswordComponent implements OnInit {
export class ResetPasswordComponent {
private fb = inject(UntypedFormBuilder);
private accountService = inject(AccountService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private injector = inject(Injector);
form!: UntypedFormGroup;
private readonly queryParams = toSignal(this.route.queryParams);
inProgress = false;
form!: UntypedFormGroup;
isPasswordReset = false;
readonly inProgress = signal(false);
readonly isPasswordReset = signal(false);
mapErrorsFn: Validation.MapErrorsFn = (errors, groupErrors, control) => {
if (PASSWORD_FIELDS.indexOf(String(control?.name)) < 0) return errors;
@ -44,9 +54,16 @@ export class ResetPasswordComponent implements OnInit {
return errors.concat(groupErrors.filter(({ key }) => key === 'passwordMismatch'));
};
ngOnInit(): void {
this.route.queryParams.subscribe(({ userId, resetToken }) => {
if (!userId || !resetToken) this.router.navigateByUrl('/account/login');
constructor() {
effect(() => {
const params = this.queryParams();
if (!params) return;
const { userId, resetToken } = params;
if (!userId || !resetToken) {
void this.router.navigateByUrl('/account/login');
return;
}
this.form = this.fb.group(
{
@ -63,9 +80,9 @@ export class ResetPasswordComponent implements OnInit {
}
onSubmit() {
if (this.form.invalid || this.inProgress) return;
if (this.form.invalid || this.inProgress()) return;
this.inProgress = true;
this.inProgress.set(true);
this.accountService
.resetPassword({
@ -73,9 +90,9 @@ export class ResetPasswordComponent implements OnInit {
resetToken: this.form.get('resetToken')?.value,
password: this.form.get('password')?.value,
})
.pipe(finalize(() => (this.inProgress = false)))
.pipe(finalize(() => this.inProgress.set(false)))
.subscribe(() => {
this.isPasswordReset = true;
this.isPasswordReset.set(true);
});
}
}

17
npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.html

@ -1,13 +1,13 @@
<abp-page [title]="'CmsKit::BlogPosts' | abpLocalization">
<div class="card">
<div class="card-body">
@if (form && (!isEditMode || blogPost)) {
<form [formGroup]="form" (ngSubmit)="saveAsDraft()" validateOnSubmit>
@if (form && (!isEditMode() || blogPost())) {
<form [formGroup]="form" (ngSubmit)="saveAsDraft()" [validateOnSubmit]="true">
<!-- Cover Image -->
<div class="mb-3">
@if (coverImagePreview) {
@if (coverImagePreview()) {
<div class="mb-2">
<img [src]="coverImagePreview" height="120" alt="Cover Image" />
<img [src]="coverImagePreview()" height="120" alt="Cover Image" />
<br />
<button type="button" class="btn btn-link p-0" (click)="removeCoverImage()">
{{ 'CmsKit::RemoveCoverImage' | abpLocalization }}
@ -28,7 +28,7 @@
</div>
<!-- Basic fields -->
<abp-extensible-form [selectedRecord]="blogPost || {}" />
<abp-extensible-form [selectedRecord]="blogPost() || {}" />
<!-- Content editor -->
<div class="mt-3">
@ -38,14 +38,15 @@
</form>
<!-- Tags Editor -->
@if (isTagsEnabled) {
@if (isTagsEnabled()) {
<hr />
<div class="mb-3">
<label class="form-label">{{ 'CmsKit::Tags' | abpLocalization }}</label>
<input
type="text"
class="form-control"
[(ngModel)]="tags"
[ngModel]="tags()"
(ngModelChange)="tags.set($event)"
[ngModelOptions]="{ standalone: true }"
[placeholder]="'CmsKit::TagsHelpText' | abpLocalization"
/>
@ -63,7 +64,7 @@
<button class="btn btn-secondary" (click)="saveAsDraft()" [disabled]="form?.invalid">
{{ 'CmsKit::SaveAsDraft' | abpLocalization }}
</button>
@if (isEditMode) {
@if (isEditMode()) {
<abp-button (click)="publish()" [disabled]="form?.invalid">
{{ 'CmsKit::Publish' | abpLocalization }}
</abp-button>

63
npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.ts

@ -1,6 +1,14 @@
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { Component, OnInit, inject, Injector, DestroyRef } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
Injector,
OnInit,
signal,
} from '@angular/core';
import { ReactiveFormsModule, FormsModule, FormGroup, FormControl } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgxValidateCoreModule } from '@ngx-validate/core';
@ -29,6 +37,7 @@ import { eCmsKitAdminComponents } from '../../../enums';
import { BlogPostFormService } from '../../../services';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-post-form',
templateUrl: './blog-post-form.component.html',
providers: [
@ -60,33 +69,33 @@ export class BlogPostFormComponent implements OnInit {
private destroyRef = inject(DestroyRef);
form: FormGroup;
blogPost: BlogPostDto | null = null;
readonly blogPost = signal<BlogPostDto | null>(null);
blogPostId: string | null = null;
isEditMode = false;
readonly isEditMode = signal(false);
coverImageFile: File | null = null;
coverImagePreview: string | null = null;
tags: string = '';
isTagsEnabled = true;
readonly coverImagePreview = signal<string | null>(null);
readonly tags = signal('');
readonly isTagsEnabled = signal(true);
readonly BLOG_POST_ENTITY_TYPE = 'BlogPost';
ngOnInit() {
const id = this.route.snapshot.params['id'];
if (id) {
this.isEditMode = true;
this.isEditMode.set(true);
this.blogPostId = id;
this.loadBlogPost(id);
} else {
this.isEditMode = false;
this.isEditMode.set(false);
this.buildForm();
}
}
private loadBlogPost(id: string) {
this.blogPostService.get(id).subscribe(blogPost => {
this.blogPost = blogPost;
this.blogPost.set(blogPost);
if (blogPost.coverImageMediaId) {
this.coverImagePreview = `/api/cms-kit/media/${blogPost.coverImageMediaId}`;
this.coverImagePreview.set(`/api/cms-kit/media/${blogPost.coverImageMediaId}`);
}
this.buildForm();
this.loadTags(id);
@ -94,7 +103,6 @@ export class BlogPostFormComponent implements OnInit {
}
private loadTags(blogPostId: string) {
// TODO: use the public service to load the tags
this.restService
.request<void, TagDto[]>({
method: 'GET',
@ -102,28 +110,27 @@ export class BlogPostFormComponent implements OnInit {
})
.subscribe(tags => {
if (tags && tags.length > 0) {
this.tags = tags.map(t => t.name || '').join(', ');
this.tags.set(tags.map(t => t.name || '').join(', '));
}
});
}
private buildForm() {
const data = new FormPropData(this.injector, this.blogPost || {});
const currentBlogPost = this.blogPost();
const data = new FormPropData(this.injector, currentBlogPost || {});
const baseForm = generateFormFromProps(data);
this.form = new FormGroup({
...baseForm.controls,
content: new FormControl(this.blogPost?.content || ''),
coverImageMediaId: new FormControl(this.blogPost?.coverImageMediaId || null),
content: new FormControl(currentBlogPost?.content || ''),
coverImageMediaId: new FormControl(currentBlogPost?.coverImageMediaId || null),
});
prepareSlugFromControl(this.form, 'title', 'slug', this.destroyRef);
// Check if tags feature is enabled for the blog
const blogId = this.form.get('blogId')?.value || this.blogPost?.blogId;
const blogId = this.form.get('blogId')?.value || currentBlogPost?.blogId;
if (blogId) {
this.checkTagsFeature(blogId);
}
// Listen for blog selection changes
this.form
.get('blogId')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
@ -142,7 +149,7 @@ export class BlogPostFormComponent implements OnInit {
})
.subscribe(feature => {
const { isEnabled } = feature || {};
this.isTagsEnabled = isEnabled;
this.isTagsEnabled.set(isEnabled);
});
}
@ -152,7 +159,7 @@ export class BlogPostFormComponent implements OnInit {
this.coverImageFile = input.files[0];
const reader = new FileReader();
reader.onload = (e: any) => {
this.coverImagePreview = e.target.result;
this.coverImagePreview.set(e.target.result);
};
reader.readAsDataURL(this.coverImageFile);
}
@ -160,7 +167,7 @@ export class BlogPostFormComponent implements OnInit {
removeCoverImage() {
this.coverImageFile = null;
this.coverImagePreview = null;
this.coverImagePreview.set(null);
this.form.patchValue({ coverImageMediaId: null });
}
@ -183,11 +190,12 @@ export class BlogPostFormComponent implements OnInit {
}
private setTags(blogPostId: string) {
if (!this.tags || !this.tags.trim()) {
const tagsValue = this.tags();
if (!tagsValue || !tagsValue.trim()) {
return of(null);
}
const tagArray = this.tags
const tagArray = tagsValue
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
@ -204,7 +212,6 @@ export class BlogPostFormComponent implements OnInit {
}
private executeSaveOperation(operation: 'save' | 'draft' | 'publish' | 'sendToReview') {
// First upload cover image if selected
this.uploadCoverImage()
.pipe(
tap(coverImageMediaId => {
@ -213,11 +220,12 @@ export class BlogPostFormComponent implements OnInit {
}
}),
switchMap(() => {
if (this.isEditMode) {
if (!this.blogPost || !this.blogPostId) {
if (this.isEditMode()) {
const currentBlogPost = this.blogPost();
if (!currentBlogPost || !this.blogPostId) {
return of(null);
}
return this.blogPostFormService.update(this.blogPostId, this.form, this.blogPost);
return this.blogPostFormService.update(this.blogPostId, this.form, currentBlogPost);
}
switch (operation) {
@ -236,7 +244,6 @@ export class BlogPostFormComponent implements OnInit {
if (!result || !result.id) {
return of(null);
}
// Set tags after blog post is created/updated
return forkJoin([of(result), this.setTags(result.id)]);
}),
)

8
npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.html

@ -1,4 +1,4 @@
<abp-page [title]="'CmsKit::BlogPosts' | abpLocalization" [toolbar]="data.items">
<abp-page [title]="'CmsKit::BlogPosts' | abpLocalization" [toolbar]="data().items">
<div class="card mb-4">
<div class="card-body">
<div class="row">
@ -35,6 +35,10 @@
</div>
<div class="card">
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" />
<abp-extensible-table
[data]="data().items"
[recordsTotal]="data().totalCount"
[list]="list"
/>
</div>
</abp-page>

55
npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.ts

@ -1,4 +1,5 @@
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core';
@ -14,6 +15,7 @@ import {
import { eCmsKitAdminComponents } from '../../../enums';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-post-list',
templateUrl: './blog-post-list.component.html',
providers: [
@ -25,21 +27,35 @@ import { eCmsKitAdminComponents } from '../../../enums';
],
imports: [ExtensibleTableComponent, PageComponent, LocalizationPipe, FormsModule, CommonModule],
})
export class BlogPostListComponent implements OnInit {
data: PagedResultDto<BlogPostListDto> = { items: [], totalCount: 0 };
export class BlogPostListComponent {
public readonly list = inject(ListService<BlogPostGetListInput>);
private blogPostService = inject(BlogPostAdminService);
private confirmationService = inject(ConfirmationService);
readonly data = toSignal(
this.list.hookToQuery(query => {
let filters: Partial<BlogPostGetListInput> = {};
if (this.list.filter) {
filters.filter = this.list.filter;
}
if (this.statusFilter !== null) {
filters.status = this.statusFilter;
}
const input: BlogPostGetListInput = {
...query,
...filters,
};
return this.blogPostService.getList(input);
}),
{
initialValue: { items: [], totalCount: 0 } as PagedResultDto<BlogPostListDto>,
},
);
filter = '';
statusFilter: BlogPostStatus | null = null;
BlogPostStatus = BlogPostStatus;
ngOnInit() {
this.hookToQuery();
}
onSearch() {
this.list.filter = this.filter;
this.list.get();
@ -49,25 +65,6 @@ export class BlogPostListComponent implements OnInit {
this.list.get();
}
private hookToQuery() {
this.list
.hookToQuery(query => {
let filters: Partial<BlogPostGetListInput> = {};
if (this.list.filter) {
filters.filter = this.list.filter;
}
if (this.statusFilter !== null) {
filters.status = this.statusFilter;
}
const input: BlogPostGetListInput = {
...query,
...filters,
};
return this.blogPostService.getList(input);
})
.subscribe(res => (this.data = res));
}
delete(id: string, title: string) {
this.confirmationService
.warn('CmsKit::BlogPostDeletionConfirmationMessage', 'AbpUi::AreYouSure', {
@ -77,9 +74,7 @@ export class BlogPostListComponent implements OnInit {
})
.subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) {
this.blogPostService.delete(id).subscribe(() => {
this.list.get();
});
this.blogPostService.delete(id).subscribe(() => this.list.get());
}
});
}

12
npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.html

@ -4,8 +4,8 @@
</ng-template>
<ng-template #abpBody>
@if (form) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit>
@if (form(); as currentForm) {
<form [formGroup]="currentForm" (ngSubmit)="save()" [validateOnSubmit]="true">
<div formArrayName="features">
@for (featureControl of featuresFormArray.controls; track $index; let i = $index) {
<div class="mb-3" [formGroupName]="i">
@ -19,7 +19,7 @@
formControlName="isEnabled"
/>
<label class="form-check-label" for="feature-{{ i }}">
{{ 'CmsKit::' + features[i]?.featureName | abpLocalization }}
{{ 'CmsKit::' + features()[i]?.featureName | abpLocalization }}
</label>
</div>
} @else {
@ -33,10 +33,10 @@
class="form-check-input"
id="feature-{{ i }}"
formControlName="isEnabled"
disabled
[disabled]="true"
/>
<label class="form-check-label" for="feature-{{ i }}">
{{ 'CmsKit::' + features[i]?.featureName | abpLocalization }}
{{ 'CmsKit::' + features()[i]?.featureName | abpLocalization }}
</label>
</div>
</div>
@ -58,7 +58,7 @@
<button type="button" class="btn btn-outline-primary" abpClose>
{{ 'AbpUi::Cancel' | abpLocalization }}
</button>
<abp-button iconClass="fa fa-check" [disabled]="form?.invalid" (click)="save()">
<abp-button iconClass="fa fa-check" [disabled]="form()?.invalid" (click)="save()">
{{ 'AbpUi::Save' | abpLocalization }}
</abp-button>
</ng-template>

55
npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.ts

@ -1,4 +1,12 @@
import { Component, OnInit, inject, input, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
input,
OnInit,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormArray, FormBuilder, FormGroup } from '@angular/forms';
import { NgxValidateCoreModule } from '@ngx-validate/core';
@ -22,6 +30,7 @@ export interface BlogFeaturesModalVisibleChange {
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-features-modal',
templateUrl: './blog-features-modal.component.html',
imports: [
@ -42,8 +51,8 @@ export class BlogFeaturesModalComponent implements OnInit {
blogId = input<string>();
visibleChange = output<BlogFeaturesModalVisibleChange>();
form: FormGroup;
features: BlogFeatureDto[] = [];
readonly form = signal<FormGroup | undefined>(undefined);
readonly features = signal<BlogFeatureDto[]>([]);
private initialFeatureStates: Map<string, boolean> = new Map();
ngOnInit() {
@ -54,19 +63,19 @@ export class BlogFeaturesModalComponent implements OnInit {
private loadFeatures() {
this.blogFeatureService.getList(this.blogId()!).subscribe(features => {
this.features = features.sort((a, b) =>
const sorted = features.sort((a, b) =>
(a.featureName || '').localeCompare(b.featureName || ''),
);
// Store initial states
this.features.set(sorted);
this.initialFeatureStates = new Map(
this.features.map(f => [f.featureName || '', f.isEnabled || false]),
sorted.map(f => [f.featureName || '', f.isEnabled || false]),
);
this.buildForm();
this.buildForm(sorted);
});
}
private buildForm() {
const featureControls = this.features.map(feature =>
private buildForm(features: BlogFeatureDto[]) {
const featureControls = features.map(feature =>
this.fb.group({
featureName: [feature.featureName],
isEnabled: [feature.isEnabled],
@ -74,13 +83,15 @@ export class BlogFeaturesModalComponent implements OnInit {
}),
);
this.form = this.fb.group({
features: this.fb.array(featureControls),
});
this.form.set(
this.fb.group({
features: this.fb.array(featureControls),
}),
);
}
get featuresFormArray(): FormArray {
return this.form.get('features') as FormArray;
return this.form()!.get('features') as FormArray;
}
onVisibleChange(visible: boolean, refresh = false) {
@ -88,20 +99,19 @@ export class BlogFeaturesModalComponent implements OnInit {
}
save() {
if (!this.form.valid || !this.blogId()) {
const currentForm = this.form();
if (!currentForm?.valid || !this.blogId()) {
return;
}
const featuresArray = this.form.get('features') as FormArray;
const featuresArray = currentForm.get('features') as FormArray;
// Only save features that have changed
const changedFeatures: BlogFeatureInputDto[] = featuresArray.controls
.map(control => {
const featureName = control.get('featureName')?.value;
const isEnabled = control.get('isEnabled')?.value;
const initialIsEnabled = this.initialFeatureStates.get(featureName);
// Only include if the value has changed
if (featureName && initialIsEnabled !== isEnabled) {
return {
featureName,
@ -112,23 +122,18 @@ export class BlogFeaturesModalComponent implements OnInit {
})
.filter((input): input is BlogFeatureInputDto => input !== null);
// If no features changed, just close the modal
if (changedFeatures.length === 0) {
this.onVisibleChange(false, false);
return;
}
// Save only changed features
const saveObservables = changedFeatures.map(input =>
this.blogFeatureService.set(this.blogId()!, input),
);
// Use forkJoin to save all changed features at once
forkJoin(saveObservables).subscribe({
next: () => {
this.onVisibleChange(false, true);
this.toasterService.success('AbpUi::SavedSuccessfully');
},
forkJoin(saveObservables).subscribe(() => {
this.onVisibleChange(false, true);
this.toasterService.success('AbpUi::SavedSuccessfully');
});
}
}

12
npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.html

@ -1,15 +1,15 @@
<abp-page [title]="'CmsKit::Blogs' | abpLocalization" [toolbar]="data.items">
<abp-page [title]="'CmsKit::Blogs' | abpLocalization" [toolbar]="data().items">
<div class="card">
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" />
<abp-extensible-table [data]="data().items" [recordsTotal]="data().totalCount" [list]="list" />
</div>
@if (isModalVisible) {
<abp-blog-modal [selected]="selected" (visibleChange)="onVisibleModalChange($event)" />
@if (isModalVisible()) {
<abp-blog-modal [selected]="selected()" (visibleChange)="onVisibleModalChange($event)" />
}
@if (isFeaturesModalVisible) {
@if (isFeaturesModalVisible()) {
<abp-blog-features-modal
[blogId]="selectedBlogId"
[blogId]="selectedBlogId()"
(visibleChange)="onFeaturesModalChange($event)"
/>
}

79
npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.ts

@ -1,4 +1,5 @@
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core';
@ -14,6 +15,7 @@ import {
} from '../blog-features-modal/blog-features-modal.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-list',
templateUrl: './blog-list.component.html',
providers: [
@ -33,22 +35,33 @@ import {
BlogFeaturesModalComponent,
],
})
export class BlogListComponent implements OnInit {
data: PagedResultDto<BlogDto> = { items: [], totalCount: 0 };
export class BlogListComponent {
public readonly list = inject(ListService<BlogGetListInput>);
private blogService = inject(BlogAdminService);
private confirmationService = inject(ConfirmationService);
filter = '';
isModalVisible = false;
selected?: BlogDto;
isFeaturesModalVisible = false;
selectedBlogId?: string;
readonly data = toSignal(
this.list.hookToQuery(query => {
let filters: Partial<BlogGetListInput> = {};
if (this.list.filter) {
filters.filter = this.list.filter;
}
const input: BlogGetListInput = {
...query,
...filters,
};
return this.blogService.getList(input);
}),
{
initialValue: { items: [], totalCount: 0 } as PagedResultDto<BlogDto>,
},
);
ngOnInit() {
this.hookToQuery();
}
filter = '';
readonly isModalVisible = signal(false);
readonly selected = signal<BlogDto | undefined>(undefined);
readonly isFeaturesModalVisible = signal(false);
readonly selectedBlogId = signal<string | undefined>(undefined);
onSearch() {
this.list.filter = this.filter;
@ -56,14 +69,14 @@ export class BlogListComponent implements OnInit {
}
add() {
this.selected = {} as BlogDto;
this.isModalVisible = true;
this.selected.set({} as BlogDto);
this.isModalVisible.set(true);
}
edit(id: string) {
this.blogService.get(id).subscribe(blog => {
this.selected = blog;
this.isModalVisible = true;
this.selected.set(blog);
this.isModalVisible.set(true);
});
}
@ -74,34 +87,14 @@ export class BlogListComponent implements OnInit {
})
.subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) {
this.blogService.delete(id).subscribe(() => {
this.list.get();
});
this.blogService.delete(id).subscribe(() => this.list.get());
}
});
}
openFeatures(id: string) {
this.selectedBlogId = id;
this.isFeaturesModalVisible = true;
}
private hookToQuery() {
this.list
.hookToQuery(query => {
let filters: Partial<BlogGetListInput> = {};
if (this.list.filter) {
filters.filter = this.list.filter;
}
const input: BlogGetListInput = {
...query,
...filters,
};
return this.blogService.getList(input);
})
.subscribe(res => {
this.data = res;
});
this.selectedBlogId.set(id);
this.isFeaturesModalVisible.set(true);
}
onVisibleModalChange(visibilityChange: BlogModalVisibleChange) {
@ -111,8 +104,8 @@ export class BlogListComponent implements OnInit {
if (visibilityChange.refresh) {
this.list.get();
}
this.selected = null;
this.isModalVisible = false;
this.selected.set(undefined);
this.isModalVisible.set(false);
}
onFeaturesModalChange(visibilityChange: BlogFeaturesModalVisibleChange) {
@ -122,7 +115,7 @@ export class BlogListComponent implements OnInit {
if (visibilityChange.refresh) {
this.list.get();
}
this.selectedBlogId = null;
this.isFeaturesModalVisible = false;
this.selectedBlogId.set(undefined);
this.isFeaturesModalVisible.set(false);
}
}

4
npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.html

@ -1,4 +1,4 @@
<abp-modal [visible]="true" (visibleChange)="onVisibleChange($event)" [busy]="modalBusy">
<abp-modal [visible]="true" (visibleChange)="onVisibleChange($event)" [busy]="modalBusy()">
<ng-template #abpHeader>
<h3>
{{ (selected()?.id ? 'AbpUi::Edit' : 'AbpUi::New') | abpLocalization }}
@ -7,7 +7,7 @@
<ng-template #abpBody>
@if (form) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit>
<form [formGroup]="form" (ngSubmit)="save()" [validateOnSubmit]="true">
<abp-extensible-form [selectedRecord]="selected()" />
</form>
} @else {

22
npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.ts

@ -1,4 +1,14 @@
import { Component, OnInit, inject, Injector, input, output, DestroyRef } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
Injector,
input,
OnInit,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormGroup } from '@angular/forms';
import { NgxValidateCoreModule } from '@ngx-validate/core';
@ -24,6 +34,7 @@ export interface BlogModalVisibleChange {
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-modal',
templateUrl: './blog-modal.component.html',
imports: [
@ -46,7 +57,7 @@ export class BlogModalComponent implements OnInit {
selected = input<BlogDto>();
visibleChange = output<BlogModalVisibleChange>();
modalBusy = false;
readonly modalBusy = signal(false);
form: FormGroup;
@ -65,12 +76,13 @@ export class BlogModalComponent implements OnInit {
}
save() {
if (this.modalBusy) {
if (this.modalBusy()) {
return;
}
this.modalBusy = true;
this.modalBusy.set(true);
if (!this.form.valid) {
this.modalBusy.set(false);
return;
}
@ -86,7 +98,7 @@ export class BlogModalComponent implements OnInit {
} as UpdateBlogDto);
}
observable$.pipe(finalize(() => (this.modalBusy = false))).subscribe(() => {
observable$.pipe(finalize(() => this.modalBusy.set(false))).subscribe(() => {
this.onVisibleChange(false, true);
this.toasterService.success('AbpUi::SavedSuccessfully');
});

2
npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.html

@ -28,7 +28,7 @@
</div>
<hr class="my-3" />
<div>
<abp-button (click)="submit()" iconClass="fa fa-save" [loading]="loading">
<abp-button (click)="submit()" iconClass="fa fa-save" [loading]="loading()">
{{ 'AbpAccount::Save' | abpLocalization }}
</abp-button>
</div>

8
npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.ts

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
NgbNav,
@ -33,11 +33,13 @@ import { CMS_KIT_COMMENTS_REQUIRE_APPROVEMENT } from '../comments';
ReactiveFormsModule,
],
})
export class CmsSettingsComponent {
export class CmsSettingsComponent implements OnInit {
readonly commentAdminService = inject(CommentAdminService);
readonly configState = inject(ConfigStateService);
readonly toaster = inject(ToasterService);
readonly loading = signal(false);
commentApprovalControl = new FormControl(false);
ngOnInit() {
@ -47,10 +49,12 @@ export class CmsSettingsComponent {
}
submit() {
this.loading.set(true);
this.commentAdminService
.updateSettings({ commentRequireApprovement: this.commentApprovalControl.value })
.pipe(
finalize(() => {
this.loading.set(false);
this.configState.refreshAppState().subscribe();
}),
)

6
npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.html

@ -1,5 +1,5 @@
<abp-page [title]="'CmsKit::Comments' | abpLocalization">
@if (comment) {
@if (comment(); as comment) {
<div class="card mb-4">
<div class="card-body">
<table class="table">
@ -59,7 +59,7 @@
<div class="card mb-4">
<div class="card-body">
<form [formGroup]="filterForm" (ngSubmit)="onFilter()">
<input type="hidden" [value]="commentId" />
<input type="hidden" [value]="commentId()" />
<div class="row">
<div class="col-lg-4 col-md-12">
<div class="row">
@ -142,6 +142,6 @@
<h3>{{ 'CmsKit::RepliesToThisComment' | abpLocalization }}</h3>
<div class="card">
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" />
<abp-extensible-table [data]="data().items" [recordsTotal]="data().totalCount" [list]="list" />
</div>
</abp-page>

46
npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.ts

@ -1,4 +1,5 @@
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule, DatePipe } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
@ -18,6 +19,7 @@ import { eCmsKitAdminComponents } from '../../../enums';
import { CommentEntityService } from '../../../services';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-comment-details',
templateUrl: './comment-details.component.html',
providers: [
@ -46,8 +48,8 @@ import { CommentEntityService } from '../../../services';
],
})
export class CommentDetailsComponent implements OnInit {
comment: CommentWithAuthorDto | null = null;
data: PagedResultDto<CommentWithAuthorDto> = { items: [], totalCount: 0 };
readonly comment = signal<CommentWithAuthorDto | null>(null);
readonly commentId = signal<string | null>(null);
readonly list = inject(ListService<CommentGetListInput>);
readonly commentEntityService = inject(CommentEntityService);
@ -57,19 +59,33 @@ export class CommentDetailsComponent implements OnInit {
private router = inject(Router);
private fb = inject(FormBuilder);
readonly data = toSignal(
this.list.hookToQuery(query => {
const filters = (this.list.filter as Partial<CommentGetListInput>) || {};
const input: CommentGetListInput = {
repliedCommentId: this.commentId() ?? undefined,
...query,
...filters,
};
return this.commentService.getList(input);
}),
{
initialValue: { items: [], totalCount: 0 } as PagedResultDto<CommentWithAuthorDto>,
},
);
filterForm!: FormGroup;
commentApproveStateOptions = commentApproveStateOptions;
commentId!: string;
requireApprovement: boolean;
ngOnInit() {
this.route.params.subscribe(params => {
const id = params['id'];
if (id) {
this.commentId = id;
this.commentId.set(id);
this.loadComment(id);
this.createFilterForm();
this.hookToQuery();
this.list.get();
}
});
this.requireApprovement = this.commentEntityService.requireApprovement;
@ -86,7 +102,7 @@ export class CommentDetailsComponent implements OnInit {
private loadComment(id: string) {
this.commentService.get(id).subscribe(comment => {
this.comment = comment;
this.comment.set(comment);
});
}
@ -95,7 +111,7 @@ export class CommentDetailsComponent implements OnInit {
const filters: Partial<CommentGetListInput> = {
author: formValue.author || undefined,
commentApproveState: formValue.commentApproveState,
repliedCommentId: this.commentId,
repliedCommentId: this.commentId() ?? undefined,
creationStartDate: formValue.creationStartDate || undefined,
creationEndDate: formValue.creationEndDate || undefined,
};
@ -104,20 +120,6 @@ export class CommentDetailsComponent implements OnInit {
this.list.get();
}
private hookToQuery() {
this.list
.hookToQuery(query => {
const filters = (this.list.filter as Partial<CommentGetListInput>) || {};
const input: CommentGetListInput = {
repliedCommentId: this.commentId,
...query,
...filters,
};
return this.commentService.getList(input);
})
.subscribe(res => (this.data = res));
}
navigateToReply(id: string) {
this.router.navigate(['/cms/comments', id]);
}

2
npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.html

@ -87,6 +87,6 @@
</div>
<div class="card">
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" />
<abp-extensible-table [data]="data().items" [recordsTotal]="data().totalCount" [list]="list" />
</div>
</abp-page>

34
npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.ts

@ -1,4 +1,5 @@
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NgbDateAdapter, NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap';
@ -17,6 +18,7 @@ import { eCmsKitAdminComponents } from '../../../enums';
import { CommentEntityService } from '../../../services';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-comment-list',
templateUrl: './comment-list.component.html',
providers: [
@ -44,21 +46,32 @@ import { CommentEntityService } from '../../../services';
],
})
export class CommentListComponent implements OnInit {
data: PagedResultDto<CommentWithAuthorDto> = { items: [], totalCount: 0 };
readonly list = inject(ListService<CommentGetListInput>);
readonly commentEntityService = inject(CommentEntityService);
private commentService = inject(CommentAdminService);
private fb = inject(FormBuilder);
readonly data = toSignal(
this.list.hookToQuery(query => {
const filters = (this.list.filter as Partial<CommentGetListInput>) || {};
const input: CommentGetListInput = {
...query,
...filters,
};
return this.commentService.getList(input);
}),
{
initialValue: { items: [], totalCount: 0 } as PagedResultDto<CommentWithAuthorDto>,
},
);
filterForm!: FormGroup;
commentApproveStateOptions = commentApproveStateOptions;
requireApprovement: boolean;
ngOnInit() {
this.createFilterForm();
this.hookToQuery();
this.requireApprovement = this.commentEntityService.requireApprovement;
}
@ -85,17 +98,4 @@ export class CommentListComponent implements OnInit {
this.list.filter = filters as any;
this.list.get();
}
private hookToQuery() {
this.list
.hookToQuery(query => {
const filters = (this.list.filter as Partial<CommentGetListInput>) || {};
const input: CommentGetListInput = {
...query,
...filters,
};
return this.commentService.getList(input);
})
.subscribe(res => (this.data = res));
}
}

4
npm/ng-packs/packages/cms-kit/admin/src/components/global-resources/global-resources.component.html

@ -1,12 +1,12 @@
<abp-page [title]="'CmsKit::GlobalResources' | abpLocalization">
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit>
<form [formGroup]="form" (ngSubmit)="save()" [validateOnSubmit]="true">
<div class="card">
<div class="card-body">
<ul
ngbNav
#nav="ngbNav"
class="nav-tabs"
[(activeId)]="activeTab"
[activeId]="activeTab()"
(activeIdChange)="onTabChange($event)"
>
<li ngbNavItem="script">

14
npm/ng-packs/packages/cms-kit/admin/src/components/global-resources/global-resources.component.ts

@ -1,4 +1,11 @@
import { Component, OnInit, inject, DestroyRef } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
OnInit,
signal,
} from '@angular/core';
import { ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
@ -15,6 +22,7 @@ import {
} from '@abp/ng.cms-kit/proxy';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-global-resources',
imports: [
CommonModule,
@ -34,7 +42,7 @@ export class GlobalResourcesComponent implements OnInit {
private destroyRef = inject(DestroyRef);
form: FormGroup;
activeTab: string = 'script';
readonly activeTab = signal('script');
ngOnInit() {
this.buildForm();
@ -66,7 +74,7 @@ export class GlobalResourcesComponent implements OnInit {
}
onTabChange(activeId: string) {
this.activeTab = activeId;
this.activeTab.set(activeId);
}
save() {

18
npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-list/menu-item-list.component.html

@ -1,12 +1,12 @@
<abp-page [title]="'CmsKit::MenuItems' | abpLocalization" [toolbar]="nodes">
<abp-page [title]="'CmsKit::MenuItems' | abpLocalization" [toolbar]="nodes()">
<div class="card">
<div class="card-body">
@if (nodes.length > 0) {
@if (nodes().length > 0) {
<abp-tree
[nodes]="nodes"
[draggable]="draggable"
[expandedKeys]="expandedKeys"
[selectedNode]="selectedNode"
[nodes]="nodes()"
[draggable]="draggable()"
[expandedKeys]="expandedKeys()"
[selectedNode]="selectedNode()"
(selectedNodeChange)="onSelectedNodeChange($event)"
[beforeDrop]="beforeDrop"
(dropOver)="onDrop($event)"
@ -47,10 +47,10 @@
</div>
</abp-page>
@if (isModalVisible) {
@if (isModalVisible()) {
<abp-menu-item-modal
[selected]="selectedMenuItem || undefined"
[parentId]="parentId"
[selected]="selectedMenuItem() || undefined"
[parentId]="parentId()"
(visibleChange)="onVisibleModalChange($event)"
/>
}

69
npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-list/menu-item-list.component.ts

@ -1,4 +1,4 @@
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { of } from 'rxjs';
import { PageComponent } from '@abp/ng.components/page';
@ -19,6 +19,7 @@ import {
} from '../menu-item-modal/menu-item-modal.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-menu-item-list',
templateUrl: './menu-item-list.component.html',
imports: [
@ -41,13 +42,13 @@ export class MenuItemListComponent implements OnInit {
private menuItemService = inject(MenuItemAdminService);
private confirmationService = inject(ConfirmationService);
nodes: any[] = [];
selectedNode: MenuItemDto | null = null;
expandedKeys: string[] = [];
draggable = true;
isModalVisible = false;
selectedMenuItem: MenuItemDto | MenuItemWithDetailsDto | null = null;
parentId: string | null = null;
readonly nodes = signal<any[]>([]);
readonly selectedNode = signal<MenuItemDto | null>(null);
readonly expandedKeys = signal<string[]>([]);
readonly draggable = signal(true);
readonly isModalVisible = signal(false);
readonly selectedMenuItem = signal<MenuItemDto | MenuItemWithDetailsDto | null>(null);
readonly parentId = signal<string | null>(null);
ngOnInit() {
this.loadMenuItems();
@ -56,11 +57,12 @@ export class MenuItemListComponent implements OnInit {
private loadMenuItems() {
this.menuItemService.getList().subscribe(result => {
if (result.items && result.items.length > 0) {
this.nodes = this.buildTreeNodes(result.items);
// Expand all nodes by default
this.expandedKeys = this.nodes.map(n => n.key);
const treeNodes = this.buildTreeNodes(result.items);
this.nodes.set(treeNodes);
this.expandedKeys.set(treeNodes.map(n => n.key));
} else {
this.nodes = [];
this.nodes.set([]);
this.expandedKeys.set([]);
}
});
}
@ -69,7 +71,6 @@ export class MenuItemListComponent implements OnInit {
const nodeMap = new Map<string, any>();
const rootNodes: any[] = [];
// First pass: create all nodes
items.forEach(item => {
const node: any = {
key: item.id,
@ -81,7 +82,6 @@ export class MenuItemListComponent implements OnInit {
nodeMap.set(item.id!, node);
});
// Second pass: build tree structure
items.forEach(item => {
const node = nodeMap.get(item.id!);
if (item.parentId) {
@ -97,7 +97,6 @@ export class MenuItemListComponent implements OnInit {
}
});
// Sort by order
const sortByOrder = (nodes: any[]) => {
nodes.sort((a, b) => (a.entity.order || 0) - (b.entity.order || 0));
nodes.forEach(node => {
@ -112,7 +111,7 @@ export class MenuItemListComponent implements OnInit {
}
onSelectedNodeChange(node: any) {
this.selectedNode = node?.entity || null;
this.selectedNode.set(node?.entity || null);
}
onDrop(event: any) {
@ -143,16 +142,10 @@ export class MenuItemListComponent implements OnInit {
};
this.menuItemService.moveMenuItem(node.id!, input).subscribe({
next: () => {
this.loadMenuItems();
},
error: () => {
// Reload to rollback
this.loadMenuItems();
},
next: () => this.loadMenuItems(),
error: () => this.loadMenuItems(),
});
} else {
// Reload to rollback
this.loadMenuItems();
}
});
@ -163,22 +156,22 @@ export class MenuItemListComponent implements OnInit {
};
add() {
this.selectedMenuItem = null;
this.parentId = null;
this.isModalVisible = true;
this.selectedMenuItem.set(null);
this.parentId.set(null);
this.isModalVisible.set(true);
}
addSubMenuItem(parentId?: string) {
this.selectedMenuItem = null;
this.parentId = parentId || null;
this.isModalVisible = true;
this.selectedMenuItem.set(null);
this.parentId.set(parentId || null);
this.isModalVisible.set(true);
}
edit(id: string) {
this.menuItemService.get(id).subscribe(menuItem => {
this.selectedMenuItem = menuItem;
this.parentId = null;
this.isModalVisible = true;
this.selectedMenuItem.set(menuItem);
this.parentId.set(null);
this.isModalVisible.set(true);
});
}
@ -189,9 +182,9 @@ export class MenuItemListComponent implements OnInit {
if (visibilityChange.refresh) {
this.loadMenuItems();
}
this.selectedMenuItem = null;
this.parentId = null;
this.isModalVisible = false;
this.selectedMenuItem.set(null);
this.parentId.set(null);
this.isModalVisible.set(false);
}
delete(id: string, displayName?: string) {
@ -204,9 +197,7 @@ export class MenuItemListComponent implements OnInit {
.subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) {
this.menuItemService.delete(id).subscribe({
next: () => {
this.loadMenuItems();
},
next: () => this.loadMenuItems(),
});
}
});

22
npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-modal/menu-item-modal.component.html

@ -11,12 +11,12 @@
<ng-template #abpBody>
@if (form) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit>
<form [formGroup]="form" (ngSubmit)="save()" [validateOnSubmit]="true">
<ul
ngbNav
#nav="ngbNav"
class="nav-tabs"
[(activeId)]="activeTab"
[activeId]="activeTab()"
(activeIdChange)="onTabChange($event)"
>
<li ngbNavItem="url">
@ -51,9 +51,9 @@
type="button"
id="pageSelectDropdown"
ngbDropdownToggle
[class.text-muted]="!selectedPage"
[class.text-muted]="!selectedPage()"
>
<span>{{ selectedPage?.title || ('CmsKit::Page' | abpLocalization) }}</span>
<span>{{ selectedPage()?.title || ('CmsKit::Page' | abpLocalization) }}</span>
</button>
<div
class="dropdown-menu w-100"
@ -65,16 +65,16 @@
<input
type="text"
class="form-control form-control-sm"
[(ngModel)]="pageSearchText"
[ngModel]="pageSearchText()"
(ngModelChange)="onPageSearchChange($event)"
[ngModelOptions]="{ standalone: true }"
(input)="onPageSearchChange($any($event.target).value)"
(click)="$event.stopPropagation()"
placeholder="{{ 'AbpUi::Search' | abpLocalization }}"
autocomplete="off"
/>
</div>
@if (filteredPages.length > 0) {
@for (page of filteredPages; track page.id) {
@if (filteredPages().length > 0) {
@for (page of filteredPages(); track page.id) {
<button
type="button"
class="dropdown-item"
@ -83,16 +83,16 @@
{{ page.title }}
</button>
}
} @else if (pageSearchText && pageSearchText.length > 0) {
} @else if (pageSearchText() && pageSearchText().length > 0) {
<div class="dropdown-item text-muted">
{{ 'No results found' }}
</div>
} @else if (pages.length === 0) {
} @else if (pages().length === 0) {
<div class="dropdown-item text-muted">
{{ 'No pages found' }}
</div>
}
@if (selectedPage) {
@if (selectedPage()) {
<div class="dropdown-divider"></div>
<button
type="button"

85
npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-modal/menu-item-modal.component.ts

@ -1,4 +1,14 @@
import { Component, OnInit, inject, Injector, input, output, DestroyRef } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
Injector,
input,
OnInit,
output,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormGroup, FormControl, FormsModule } from '@angular/forms';
@ -43,6 +53,7 @@ const TABS = {
} as const;
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-menu-item-modal',
templateUrl: './menu-item-modal.component.html',
imports: [
@ -81,13 +92,13 @@ export class MenuItemModalComponent implements OnInit {
// Form state
form: FormGroup;
activeTab: string = TABS.URL;
readonly activeTab = signal<string>(TABS.URL);
// Page selection state
pages: PageLookupDto[] = [];
selectedPage: PageLookupDto | null = null;
pageSearchText: string = '';
filteredPages: PageLookupDto[] = [];
// Page search state
readonly pages = signal<PageLookupDto[]>([]);
readonly selectedPage = signal<PageLookupDto | null>(null);
readonly pageSearchText = signal('');
readonly filteredPages = signal<PageLookupDto[]>([]);
// Search subject for debouncing
private readonly pageSearchSubject = new Subject<string>();
@ -123,10 +134,10 @@ export class MenuItemModalComponent implements OnInit {
)
.subscribe({
next: result => {
this.filteredPages = result.items || [];
this.filteredPages.set(result.items || []);
},
error: () => {
this.filteredPages = [];
this.filteredPages.set([]);
},
});
}
@ -156,8 +167,8 @@ export class MenuItemModalComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: ({ menuItem, pages }) => {
this.pages = pages.items || [];
this.filteredPages = this.pages;
this.pages.set(pages.items || []);
this.filteredPages.set(pages.items || []);
this.buildForm(menuItem);
this.initializePageSelection(menuItem);
},
@ -176,8 +187,8 @@ export class MenuItemModalComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: result => {
this.pages = result.items || [];
this.filteredPages = this.pages;
this.pages.set(result.items || []);
this.filteredPages.set(result.items || []);
},
error: () => {
this.toasterService.error('AbpUi::ErrorMessage');
@ -190,16 +201,15 @@ export class MenuItemModalComponent implements OnInit {
*/
private initializePageSelection(menuItem: MenuItemWithDetailsDto | MenuItemDto): void {
if (menuItem.pageId) {
this.activeTab = TABS.PAGE;
this.selectedPage = this.pages.find(p => p.id === menuItem.pageId) || null;
this.activeTab.set(TABS.PAGE);
const page = this.pages().find(p => p.id === menuItem.pageId) || null;
this.selectedPage.set(page);
const url = this.selectedPage
? this.generateUrlFromPage(this.selectedPage)
: menuItem.url || '';
const url = page ? this.generateUrlFromPage(page) : menuItem.url || '';
this.form.patchValue({ pageId: menuItem.pageId, url }, { emitEvent: false });
this.pageSearchText = this.selectedPage?.title || '';
this.pageSearchText.set(page?.title || '');
} else if (menuItem.url) {
this.activeTab = TABS.URL;
this.activeTab.set(TABS.URL);
this.form.patchValue({ url: menuItem.url, pageId: null }, { emitEvent: false });
}
}
@ -220,9 +230,9 @@ export class MenuItemModalComponent implements OnInit {
* Handles page search input changes
*/
onPageSearchChange(searchText: string): void {
this.pageSearchText = searchText;
this.pageSearchText.set(searchText);
if (!searchText?.trim()) {
this.filteredPages = this.pages;
this.filteredPages.set(this.pages());
return;
}
this.pageSearchSubject.next(searchText);
@ -232,8 +242,8 @@ export class MenuItemModalComponent implements OnInit {
* Handles dropdown open event
*/
onDropdownOpen(): void {
if (!this.pageSearchText?.trim()) {
this.filteredPages = this.pages;
if (!this.pageSearchText()?.trim()) {
this.filteredPages.set(this.pages());
}
}
@ -243,11 +253,11 @@ export class MenuItemModalComponent implements OnInit {
selectPage(page: PageLookupDto): void {
if (!page) return;
this.selectedPage = page;
this.selectedPage.set(page);
const url = this.generateUrlFromPage(page);
this.form.patchValue({ pageId: page.id, url }, { emitEvent: false });
this.pageSearchText = page.title || '';
this.pageSearchText.set(page.title || '');
}
/**
@ -255,9 +265,9 @@ export class MenuItemModalComponent implements OnInit {
*/
clearPageSelection(): void {
this.form.patchValue({ pageId: null }, { emitEvent: false });
this.selectedPage = null;
this.pageSearchText = '';
this.filteredPages = this.pages;
this.selectedPage.set(null);
this.pageSearchText.set('');
this.filteredPages.set(this.pages());
}
/**
@ -282,7 +292,7 @@ export class MenuItemModalComponent implements OnInit {
* Loads the available menu order for new menu items
*/
private loadAvailableOrder(parentId: string | null, menuItemId?: string): void {
if (menuItemId) return; // Only needed for new items
if (menuItemId) return;
const order$ = parentId
? this.menuItemService.getAvailableMenuOrder(parentId)
@ -306,7 +316,7 @@ export class MenuItemModalComponent implements OnInit {
* Handles tab changes
*/
onTabChange(activeId: string): void {
this.activeTab = activeId;
this.activeTab.set(activeId);
}
/**
@ -316,8 +326,8 @@ export class MenuItemModalComponent implements OnInit {
const urlValue = this.form.get('url')?.value;
if (urlValue && this.form.get('pageId')?.value) {
this.clearPageSelection();
if (this.activeTab === TABS.PAGE) {
this.activeTab = TABS.URL;
if (this.activeTab() === TABS.PAGE) {
this.activeTab.set(TABS.URL);
}
}
}
@ -356,17 +366,14 @@ export class MenuItemModalComponent implements OnInit {
const formValue = { ...this.form.value };
if (formValue.pageId) {
// If pageId is set, generate URL from the page
const selectedPage = this.pages.find(p => p.id === formValue.pageId);
if (selectedPage) {
formValue.url = this.generateUrlFromPage(selectedPage);
const page = this.pages().find(p => p.id === formValue.pageId);
if (page) {
formValue.url = this.generateUrlFromPage(page);
}
} else if (formValue.url) {
// If URL is manually entered, ensure pageId is cleared
formValue.pageId = null;
}
// Clean up undefined values
return {
...formValue,
url: formValue.url || undefined,

6
npm/ng-packs/packages/cms-kit/admin/src/components/pages/page-form/page-form.component.html

@ -1,10 +1,10 @@
<abp-page [title]="'CmsKit::Pages' | abpLocalization">
<div class="card">
<div class="card-body">
@if (form && (!isEditMode || page)) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit>
@if (form && (!isEditMode() || page())) {
<form [formGroup]="form" (ngSubmit)="save()" [validateOnSubmit]="true">
<!-- Basic fields outside tabs -->
<abp-extensible-form [selectedRecord]="page || {}" />
<abp-extensible-form [selectedRecord]="page() || {}" />
<!-- Tabs for Content, Script, and Style -->
<ul ngbNav #nav="ngbNav" class="nav-tabs mt-3">

41
npm/ng-packs/packages/cms-kit/admin/src/components/pages/page-form/page-form.component.ts

@ -1,4 +1,12 @@
import { Component, OnInit, inject, Injector, DestroyRef } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
Injector,
OnInit,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
@ -23,6 +31,7 @@ import { eCmsKitAdminComponents } from '../../../enums';
import { PageFormService } from '../../../services';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-page-form',
templateUrl: './page-form.component.html',
providers: [
@ -52,56 +61,58 @@ export class PageFormComponent implements OnInit {
private destroyRef = inject(DestroyRef);
form: FormGroup;
page: PageDto | null = null;
readonly page = signal<PageDto | null>(null);
pageId: string | null = null;
isEditMode = false;
readonly isEditMode = signal(false);
ngOnInit() {
const id = this.route.snapshot.params['id'];
if (id) {
this.isEditMode = true;
this.isEditMode.set(true);
this.pageId = id;
this.loadPage(id);
} else {
this.isEditMode = false;
this.isEditMode.set(false);
this.buildForm();
}
}
private loadPage(id: string) {
this.pageService.get(id).subscribe(page => {
this.page = page;
this.page.set(page);
this.buildForm();
});
}
private buildForm() {
const data = new FormPropData(this.injector, this.page || {});
const currentPage = this.page();
const data = new FormPropData(this.injector, currentPage || {});
const baseForm = generateFormFromProps(data);
this.form = new FormGroup({
...baseForm.controls,
content: new FormControl(this.page?.content || ''),
script: new FormControl(this.page?.script || ''),
style: new FormControl(this.page?.style || ''),
content: new FormControl(currentPage?.content || ''),
script: new FormControl(currentPage?.script || ''),
style: new FormControl(currentPage?.style || ''),
});
prepareSlugFromControl(this.form, 'title', 'slug', this.destroyRef);
}
private executeSaveOperation(operation: 'save' | 'draft' | 'publish') {
if (this.isEditMode) {
if (!this.page || !this.pageId) {
if (this.isEditMode()) {
const currentPage = this.page();
if (!currentPage || !this.pageId) {
return;
}
switch (operation) {
case 'save':
this.pageFormService.update(this.pageId, this.form, this.page).subscribe();
this.pageFormService.update(this.pageId, this.form, currentPage).subscribe();
break;
case 'draft':
this.pageFormService.updateAsDraft(this.pageId, this.form, this.page).subscribe();
this.pageFormService.updateAsDraft(this.pageId, this.form, currentPage).subscribe();
break;
case 'publish':
this.pageFormService.updateAndPublish(this.pageId, this.form, this.page).subscribe();
this.pageFormService.updateAndPublish(this.pageId, this.form, currentPage).subscribe();
break;
}
return;

8
npm/ng-packs/packages/cms-kit/admin/src/components/pages/page-list/page-list.component.html

@ -1,4 +1,4 @@
<abp-page [title]="'CmsKit::Pages' | abpLocalization" [toolbar]="data.items">
<abp-page [title]="'CmsKit::Pages' | abpLocalization" [toolbar]="data().items">
<div class="card mb-4">
<div class="card-body">
<div class="row">
@ -21,6 +21,10 @@
</div>
<div class="card">
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" />
<abp-extensible-table
[data]="data().items"
[recordsTotal]="data().totalCount"
[list]="list"
/>
</div>
</abp-page>

51
npm/ng-packs/packages/cms-kit/admin/src/components/pages/page-list/page-list.component.ts

@ -1,4 +1,5 @@
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core';
@ -9,6 +10,7 @@ import { PageAdminService, GetPagesInputDto, PageDto } from '@abp/ng.cms-kit/pro
import { eCmsKitAdminComponents } from '../../../enums';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-page-list',
templateUrl: './page-list.component.html',
providers: [
@ -20,43 +22,36 @@ import { eCmsKitAdminComponents } from '../../../enums';
],
imports: [ExtensibleTableComponent, PageComponent, LocalizationPipe, FormsModule, CommonModule],
})
export class PageListComponent implements OnInit {
data: PagedResultDto<PageDto> = { items: [], totalCount: 0 };
export class PageListComponent {
public readonly list = inject(ListService<GetPagesInputDto>);
private pageService = inject(PageAdminService);
private confirmationService = inject(ConfirmationService);
private toasterService = inject(ToasterService);
filter = '';
readonly data = toSignal(
this.list.hookToQuery(query => {
let filters: Partial<GetPagesInputDto> = {};
if (this.list.filter) {
filters.filter = this.list.filter;
}
const input: GetPagesInputDto = {
...query,
...filters,
};
return this.pageService.getList(input);
}),
{
initialValue: { items: [], totalCount: 0 } as PagedResultDto<PageDto>,
},
);
ngOnInit() {
this.hookToQuery();
}
filter = '';
onSearch() {
this.list.filter = this.filter;
this.list.get();
}
private hookToQuery() {
this.list
.hookToQuery(query => {
let filters: Partial<GetPagesInputDto> = {};
if (this.list.filter) {
filters.filter = this.list.filter;
}
const input: GetPagesInputDto = {
...query,
...filters,
};
return this.pageService.getList(input);
})
.subscribe(res => {
this.data = res;
});
}
delete(id: string) {
this.confirmationService
.warn('CmsKit::PageDeletionConfirmationMessage', 'AbpUi::AreYouSure', {
@ -65,9 +60,7 @@ export class PageListComponent implements OnInit {
})
.subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) {
this.pageService.delete(id).subscribe(() => {
this.list.get();
});
this.pageService.delete(id).subscribe(() => this.list.get());
}
});
}

8
npm/ng-packs/packages/cms-kit/admin/src/components/tags/tag-list/tag-list.component.html

@ -1,4 +1,4 @@
<abp-page [title]="'CmsKit::Tags' | abpLocalization" [toolbar]="data.items">
<abp-page [title]="'CmsKit::Tags' | abpLocalization" [toolbar]="data().items">
<div class="card mb-4">
<div class="card-body">
<div class="row">
@ -21,10 +21,10 @@
</div>
<div class="card">
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" />
<abp-extensible-table [data]="data().items" [recordsTotal]="data().totalCount" [list]="list" />
</div>
@if (isModalVisible) {
<abp-tag-modal [selected]="selected" (visibleChange)="onVisibleModalChange($event)" />
@if (isModalVisible()) {
<abp-tag-modal [selected]="selected()" (visibleChange)="onVisibleModalChange($event)" />
}
</abp-page>

77
npm/ng-packs/packages/cms-kit/admin/src/components/tags/tag-list/tag-list.component.ts

@ -1,4 +1,5 @@
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core';
@ -10,6 +11,7 @@ import { eCmsKitAdminComponents } from '../../../enums';
import { TagModalComponent, TagModalVisibleChange } from '../tag-modal/tag-modal.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-tag-list',
templateUrl: './tag-list.component.html',
providers: [
@ -28,28 +30,35 @@ import { TagModalComponent, TagModalVisibleChange } from '../tag-modal/tag-modal
TagModalComponent,
],
})
export class TagListComponent implements OnInit {
data: PagedResultDto<TagDto> = { items: [], totalCount: 0 };
export class TagListComponent {
public readonly list = inject(ListService<TagGetListInput>);
private tagService = inject(TagAdminService);
private confirmationService = inject(ConfirmationService);
filter = '';
isModalVisible = false;
selected?: TagDto;
tagDefinitions: TagDefinitionDto[] = [];
readonly data = toSignal(
this.list.hookToQuery(query => {
let filters: Partial<TagGetListInput> = {};
if (this.list.filter) {
filters.filter = this.list.filter;
}
const input: TagGetListInput = {
...query,
...filters,
};
return this.tagService.getList(input);
}),
{
initialValue: { items: [], totalCount: 0 } as PagedResultDto<TagDto>,
},
);
ngOnInit() {
this.loadTagDefinitions();
this.hookToQuery();
}
readonly tagDefinitions = toSignal(this.tagService.getTagDefinitions(), {
initialValue: [] as TagDefinitionDto[],
});
private loadTagDefinitions() {
this.tagService.getTagDefinitions().subscribe(definitions => {
this.tagDefinitions = definitions;
});
}
filter = '';
readonly isModalVisible = signal(false);
readonly selected = signal<TagDto | undefined>(undefined);
onSearch() {
this.list.filter = this.filter;
@ -57,35 +66,17 @@ export class TagListComponent implements OnInit {
}
add() {
this.selected = {} as TagDto;
this.isModalVisible = true;
this.selected.set({} as TagDto);
this.isModalVisible.set(true);
}
edit(id: string) {
this.tagService.get(id).subscribe(tag => {
this.selected = tag;
this.isModalVisible = true;
this.selected.set(tag);
this.isModalVisible.set(true);
});
}
private hookToQuery() {
this.list
.hookToQuery(query => {
let filters: Partial<TagGetListInput> = {};
if (this.list.filter) {
filters.filter = this.list.filter;
}
const input: TagGetListInput = {
...query,
...filters,
};
return this.tagService.getList(input);
})
.subscribe(res => {
this.data = res;
});
}
onVisibleModalChange(visibilityChange: TagModalVisibleChange) {
if (visibilityChange.visible) {
return;
@ -93,8 +84,8 @@ export class TagListComponent implements OnInit {
if (visibilityChange.refresh) {
this.list.get();
}
this.selected = null;
this.isModalVisible = false;
this.selected.set(undefined);
this.isModalVisible.set(false);
}
delete(id: string, name: string) {
@ -104,9 +95,7 @@ export class TagListComponent implements OnInit {
})
.subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) {
this.tagService.delete(id).subscribe(() => {
this.list.get();
});
this.tagService.delete(id).subscribe(() => this.list.get());
}
});
}

2
npm/ng-packs/packages/cms-kit/admin/src/components/tags/tag-modal/tag-modal.component.html

@ -7,7 +7,7 @@
<ng-template #abpBody>
@if (form) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit>
<form [formGroup]="form" (ngSubmit)="save()" [validateOnSubmit]="true">
<abp-extensible-form [selectedRecord]="selected()" />
</form>
} @else {

11
npm/ng-packs/packages/cms-kit/admin/src/components/tags/tag-modal/tag-modal.component.ts

@ -1,4 +1,12 @@
import { Component, OnInit, inject, Injector, input, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
Injector,
input,
OnInit,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormGroup } from '@angular/forms';
import { NgxValidateCoreModule } from '@ngx-validate/core';
@ -22,6 +30,7 @@ export interface TagModalVisibleChange {
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-tag-modal',
templateUrl: './tag-modal.component.html',
imports: [

6
npm/ng-packs/packages/cms-kit/admin/src/tests/blog-post-form.service.spec.ts

@ -58,7 +58,7 @@ describe('BlogPostFormService', () => {
it('should throw when creating with invalid form', () => {
const form = createInvalidForm();
expect(() => service.create(form)).toThrowError('Form is invalid');
expect(() => service.create(form)).toThrow('Form is invalid');
});
it('should call BlogPostAdminService.create and navigate on create', done => {
@ -114,12 +114,12 @@ describe('BlogPostFormService', () => {
it('should throw when updating with invalid form or missing blog post', () => {
const form = createInvalidForm();
expect(() => service.update('id', form, {} as any)).toThrowError(
expect(() => service.update('id', form, {} as any)).toThrow(
'Form is invalid or blog post is missing',
);
const validForm = createValidForm();
expect(() => service.update('id', validForm, null as any)).toThrowError(
expect(() => service.update('id', validForm, null as any)).toThrow(
'Form is invalid or blog post is missing',
);
});

6
npm/ng-packs/packages/cms-kit/admin/src/tests/page-form.service.spec.ts

@ -59,7 +59,7 @@ describe('PageFormService', () => {
it('should throw when creating with invalid form', () => {
const form = createInvalidForm();
expect(() => service.create(form)).toThrowError('Form is invalid');
expect(() => service.create(form)).toThrow('Form is invalid');
});
it('should call PageAdminService.create and navigate on create', done => {
@ -103,12 +103,12 @@ describe('PageFormService', () => {
it('should throw when updating with invalid form or missing page', () => {
const form = createInvalidForm();
expect(() => service.update('id', form, {} as any)).toThrowError(
expect(() => service.update('id', form, {} as any)).toThrow(
'Form is invalid or page is missing',
);
const validForm = createValidForm();
expect(() => service.update('id', validForm, null as any)).toThrowError(
expect(() => service.update('id', validForm, null as any)).toThrow(
'Form is invalid or page is missing',
);
});

10
npm/ng-packs/packages/cms-kit/src/components/code-mirror-editor/code-mirror-editor.component.ts

@ -1,10 +1,18 @@
import { Component, ElementRef, AfterViewInit, ViewChild, forwardRef } from '@angular/core';
import {
Component,
ElementRef,
AfterViewInit,
ViewChild,
forwardRef,
ChangeDetectionStrategy,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { EditorState } from '@codemirror/state';
import { EditorView, lineNumbers } from '@codemirror/view';
import { basicSetup } from 'codemirror';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-codemirror-editor',
template: `<div #cmHost class="codemirror-container"></div>`,
providers: [

8
npm/ng-packs/packages/cms-kit/src/components/toast-ui/toastui-editor.component.ts

@ -2,14 +2,15 @@ import { isPlatformBrowser } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
Component,
AfterViewInit,
ViewChild,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
forwardRef,
inject,
PLATFORM_ID,
DestroyRef,
ViewChild,
} from '@angular/core';
import Editor from '@toast-ui/editor';
@ -17,6 +18,7 @@ import { AbpLocalStorageService } from '@abp/ng.core';
import { THEME_CHANGE_TOKEN } from '@abp/ng.theme.shared';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-toastui-editor',
template: `<div #editorContainer></div>`,
providers: [

4
npm/ng-packs/packages/components/chart.js/src/chart.component.ts

@ -1,7 +1,6 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
OnDestroy,
@ -36,7 +35,6 @@ let Chart: any;
})
export class ChartComponent implements AfterViewInit, OnDestroy {
el = inject(ElementRef);
private cdr = inject(ChangeDetectorRef);
readonly type = input.required<string>();
readonly data = input<any>({});
@ -129,7 +127,6 @@ export class ChartComponent implements AfterViewInit, OnDestroy {
refresh = () => {
if (this.chart) {
this.chart.update();
this.cdr.detectChanges();
}
};
@ -146,4 +143,3 @@ export class ChartComponent implements AfterViewInit, OnDestroy {
}
}
}

2
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.html

@ -22,7 +22,7 @@
<!-- Array Items -->
<div class="array-items" role="list">
@for (item of formArray.controls; track trackByIndex($index)) {
@for (item of formArray.controls; track $index + '-' + formArrayVersion()) {
<div class="array-item border rounded p-3 mb-3" role="listitem" [formGroup]="getItemFormGroup($index)">
<!-- Item Header -->

14
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-array/dynamic-form-array.component.ts

@ -3,7 +3,7 @@ import {
Component,
input,
inject,
ChangeDetectorRef,
signal,
forwardRef,
} from '@angular/core';
import { FormGroup, FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms';
@ -35,7 +35,7 @@ export class DynamicFormArrayComponent {
private fb = inject(FormBuilder);
private dynamicFormService = inject(DynamicFormService);
private cdr = inject(ChangeDetectorRef);
readonly formArrayVersion = signal(0);
get formArray(): FormArray {
return this.formGroup().get(this.arrayConfig().key) as FormArray;
@ -58,20 +58,20 @@ export class DynamicFormArrayComponent {
addItem() {
if (!this.canAddItem) return;
const itemGroup = this.dynamicFormService.createFormGroup(
this.arrayConfig().children || []
);
this.formArray.push(itemGroup);
this.cdr.markForCheck();
this.formArrayVersion.update(version => version + 1);
}
removeItem(index: number) {
if (!this.canRemoveItem) return;
this.formArray.removeAt(index);
this.cdr.markForCheck();
this.formArrayVersion.update(version => version + 1);
}
getItemFormGroup(index: number): FormGroup {

6
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts

@ -1,5 +1,4 @@
import {
Component,
import {Component,
ViewContainerRef,
ChangeDetectionStrategy,
forwardRef,
@ -8,8 +7,7 @@ import {
DestroyRef,
inject,
input,
viewChild
} from '@angular/core';
viewChild,} from '@angular/core';
import {
ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl, ReactiveFormsModule
} from '@angular/forms';

710
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.html

@ -1,355 +1,368 @@
@if (visible()) {
<div [formGroup]="fieldFormGroup" role="group" [attr.aria-labelledby]="fieldId + '-label'">
<!-- NOTE: Group and Array types are NOT rendered here, they should be rendered at parent level -->
<!-- This component only handles leaf/primitive field types -->
@if (field().type === 'text') {
<!-- Text Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
[id]="fieldId"
[placeholder]="(field().placeholder || '') | abpLocalization"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'select') {
<!-- Select Dropdown -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<select
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
<option value="">{{ '::Select' | abpLocalization }}</option>
@for (option of options$ | async; track option.key) {
<option [value]="option.key">
{{ option.value | abpLocalization }}
</option>
}
</select>
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'checkbox') {
<!-- Checkbox -->
<div class="form-group form-check mb-3" role="group">
<abp-checkbox
[label]="field().label | abpLocalization"
formControlName="value"
[id]="fieldId"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null" />
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'email') {
<!-- Email Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="email"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="email">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'textarea') {
<!-- Textarea -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<textarea
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
rows="4"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
<div [formGroup]="fieldFormGroup" role="group" [attr.aria-labelledby]="fieldId + '-label'">
<!-- NOTE: Group and Array types are NOT rendered here, they should be rendered at parent level -->
<!-- This component only handles leaf/primitive field types -->
@if (field().type === 'text') {
<!-- Text Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
[id]="fieldId"
[placeholder]="field().placeholder || '' | abpLocalization"
formControlName="value"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'select') {
<!-- Select Dropdown -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<select
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
>
<option value="">{{ '::Select' | abpLocalization }}</option>
@for (option of options$ | async; track option.key) {
<option [value]="option.key">
{{ option.value | abpLocalization }}
</option>
}
</select>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'checkbox') {
<!-- Checkbox -->
<div class="form-group form-check mb-3" role="group">
<abp-checkbox
[label]="field().label | abpLocalization"
formControlName="value"
[checkboxId]="fieldId"
[id]="fieldId"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'email') {
<!-- Email Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="email"
[id]="fieldId"
formControlName="value"
[placeholder]="field().placeholder || '' | abpLocalization"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="email"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'textarea') {
<!-- Textarea -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<textarea
[id]="fieldId"
formControlName="value"
[placeholder]="field().placeholder || '' | abpLocalization"
[class.is-invalid]="isInvalid()"
rows="4"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
>
</textarea>
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'number') {
<!-- Number Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="number"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.step]="field().step || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'date') {
<!-- Date Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="date"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'datetime-local') {
<!-- DateTime Local Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="datetime-local"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'time') {
<!-- Time Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="time"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.step]="field().step || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'password') {
<!-- Password Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="password"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.minlength]="field().minLength || null"
[attr.maxlength]="field().maxLength || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="new-password">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'tel') {
<!-- Tel Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="tel"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.pattern]="field().pattern || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="tel">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'url') {
<!-- URL Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="url"
[id]="fieldId"
formControlName="value"
[placeholder]="(field().placeholder || '') | abpLocalization"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="url">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'radio') {
<!-- Radio Group -->
<div class="form-group mb-3" role="radiogroup" [attr.aria-labelledby]="fieldId + '-label'">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<div class="radio-group">
@for (option of options$ | async; track option.key) {
<div class="form-check">
<input
type="radio"
[id]="fieldId + '-' + option.key"
[value]="option.key"
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'number') {
<!-- Number Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="number"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-check-input"
[placeholder]="field().placeholder || '' | abpLocalization"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.step]="field().step || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null">
<label [for]="fieldId + '-' + option.key" class="form-check-label">
{{ option.value | abpLocalization }}
</label>
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'date') {
<!-- Date Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="date"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'datetime-local') {
<!-- DateTime Local Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="datetime-local"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'time') {
<!-- Time Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="time"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.min]="field().min || null"
[attr.max]="field().max || null"
[attr.step]="field().step || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'password') {
<!-- Password Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="password"
[id]="fieldId"
formControlName="value"
[placeholder]="field().placeholder || '' | abpLocalization"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.minlength]="field().minLength || null"
[attr.maxlength]="field().maxLength || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="new-password"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'tel') {
<!-- Tel Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="tel"
[id]="fieldId"
formControlName="value"
[placeholder]="field().placeholder || '' | abpLocalization"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.pattern]="field().pattern || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="tel"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'url') {
<!-- URL Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="url"
[id]="fieldId"
formControlName="value"
[placeholder]="field().placeholder || '' | abpLocalization"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
autocomplete="url"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'radio') {
<!-- Radio Group -->
<div class="form-group mb-3" role="radiogroup" [attr.aria-labelledby]="fieldId + '-label'">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<div class="radio-group">
@for (option of options$ | async; track option.key) {
<div class="form-check">
<input
type="radio"
[id]="fieldId + '-' + option.key"
[value]="option.key"
formControlName="value"
[class.is-invalid]="isInvalid()"
class="form-check-input"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
/>
<label [for]="fieldId + '-' + option.key" class="form-check-label">
{{ option.value | abpLocalization }}
</label>
</div>
}
</div>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'file') {
<!-- File Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="file"
[id]="fieldId"
(change)="onFileChange($event)"
[class.is-invalid]="isInvalid()"
class="form-control"
[attr.accept]="field().accept || null"
[attr.multiple]="field().multiple || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
/>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'range') {
<!-- Range Slider -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<div class="d-flex align-items-center gap-3">
<input
type="range"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid()"
class="form-range flex-grow-1"
[attr.min]="field().min || 0"
[attr.max]="field().max || 100"
[attr.step]="field().step || 1"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
[attr.aria-valuenow]="value.value"
[attr.aria-valuemin]="field().min || 0"
[attr.aria-valuemax]="field().max || 100"
/>
<output [for]="fieldId" class="badge bg-primary">{{ value.value }}</output>
</div>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'color') {
<!-- Color Picker -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<div class="d-flex align-items-center gap-3">
<input
type="color"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid()"
class="form-control form-control-color"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
/>
<code class="text-muted">{{ value.value || '#000000' }}</code>
</div>
@if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
}
</div>
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'file') {
<!-- File Input -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<input
type="file"
[id]="fieldId"
(change)="onFileChange($event)"
[class.is-invalid]="isInvalid"
class="form-control"
[attr.accept]="field().accept || null"
[attr.multiple]="field().multiple || null"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'range') {
<!-- Range Slider -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<div class="d-flex align-items-center gap-3">
<input
type="range"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-range flex-grow-1"
[attr.min]="field().min || 0"
[attr.max]="field().max || 100"
[attr.step]="field().step || 1"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"
[attr.aria-valuenow]="value.value"
[attr.aria-valuemin]="field().min || 0"
[attr.aria-valuemax]="field().max || 100">
<output [for]="fieldId" class="badge bg-primary">{{ value.value }}</output>
</div>
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
} @else if (field().type === 'color') {
<!-- Color Picker -->
<div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" />
<div class="d-flex align-items-center gap-3">
<input
type="color"
[id]="fieldId"
formControlName="value"
[class.is-invalid]="isInvalid"
class="form-control form-control-color"
[attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null"
[attr.aria-describedby]="isInvalid ? errorId : null"
[attr.aria-label]="field().label | abpLocalization">
<code class="text-muted">{{ value.value || '#000000' }}</code>
</div>
@if (isInvalid) {
<ng-container [ngTemplateOutlet]="errorTemplate" />
}
</div>
}
</div>
}
<ng-template #labelTemplate>
<label
[for]="fieldId"
[id]="fieldId + '-label'"
[class.required]="field().required">
<label [for]="fieldId" [id]="fieldId + '-label'" [class.required]="field().required">
{{ field().label | abpLocalization }}
@if (field().required) {
<span class="text-danger" aria-label="required">*</span>
@ -358,14 +371,9 @@
</ng-template>
<ng-template #errorTemplate>
<div
class="invalid-feedback"
[id]="errorId"
role="alert"
aria-live="polite"
aria-atomic="true">
@for (error of errors; track error) {
<div>{{ error | abpLocalization }}</div>
<div class="invalid-feedback" [id]="errorId" role="alert" aria-live="polite" aria-atomic="true">
@for (error of errors(); track error) {
<div>{{ error | abpLocalization }}</div>
}
</div>
</ng-template>
</ng-template>

97
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field.component.ts

@ -1,13 +1,15 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef,
forwardRef,
inject,
InjectionToken, Injector,
InjectionToken,
Injector,
input,
OnInit,
signal,
} from '@angular/core';
import { FormFieldConfig } from '../dynamic-form.models';
import {
@ -21,6 +23,7 @@ import {
FormGroup,
ReactiveFormsModule,
} from '@angular/forms';
import { merge } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgTemplateOutlet, AsyncPipe } from '@angular/common';
import { LocalizationPipe } from '@abp/ng.core';
@ -28,7 +31,9 @@ import { FormCheckboxComponent } from '@abp/ng.theme.shared';
import { Observable, of } from 'rxjs';
import { DynamicFormService } from '../dynamic-form.service';
export const ABP_DYNAMIC_FORM_FIELD = new InjectionToken<DynamicFormFieldComponent>('AbpDynamicFormField');
export const ABP_DYNAMIC_FORM_FIELD = new InjectionToken<DynamicFormFieldComponent>(
'AbpDynamicFormField',
);
const DYNAMIC_FORM_FIELD_CONTROL_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
@ -47,18 +52,24 @@ const DYNAMIC_FORM_FIELD_CONTROL_VALUE_ACCESSOR = {
host: { class: 'abp-dynamic-form-field' },
exportAs: 'abpDynamicFormField',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgTemplateOutlet, LocalizationPipe, ReactiveFormsModule, FormCheckboxComponent, AsyncPipe],
imports: [
NgTemplateOutlet,
LocalizationPipe,
ReactiveFormsModule,
FormCheckboxComponent,
AsyncPipe,
],
})
export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
field = input.required<FormFieldConfig>();
visible = input<boolean>(true);
control!: FormControl;
fieldFormGroup: FormGroup;
readonly changeDetectorRef = inject(ChangeDetectorRef);
readonly destroyRef = inject(DestroyRef);
private injector = inject(Injector);
private formBuilder = inject(FormBuilder);
private dynamicFormService = inject(DynamicFormService);
private readonly controlStateTick = signal(0);
options$: Observable<{ key: string; value: any }[]> = of([]);
@ -75,6 +86,38 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
return `${this.fieldId}-help`;
}
readonly isInvalid = computed(() => {
this.controlStateTick();
if (this.control) {
return this.control.invalid && (this.control.dirty || this.control.touched);
}
return false;
});
readonly errors = computed(() => {
this.controlStateTick();
if (!this.control?.errors) return [];
if (this.control && this.control.errors) {
const errorKeys = Object.keys(this.control.errors);
const validators = this.field().validators || [];
return errorKeys.map(key => {
const validator = validators.find(v => v.type.toLowerCase() === key.toLowerCase());
if (validator && validator.message) {
return validator.message;
}
// Fallback error messages
if (key === 'required') return `${this.field().label} is required`;
if (key === 'email') return 'Please enter a valid email address';
if (key === 'minlength')
return `Minimum length is ${this.control.errors[key].requiredLength}`;
if (key === 'maxlength')
return `Maximum length is ${this.control.errors[key].requiredLength}`;
return `${this.field().label} is invalid due to ${key} validation.`;
});
}
return [];
});
constructor() {
this.fieldFormGroup = this.formBuilder.group({
value: [{ value: '' }],
@ -85,6 +128,9 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
const ngControl = this.injector.get(NgControl, null);
if (ngControl) {
this.control = this.injector.get(FormGroupDirective).getControl(ngControl as FormControlName);
merge(this.control.statusChanges, this.control.valueChanges)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.controlStateTick.update(tick => tick + 1));
}
this.value.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.onChange(value);
@ -99,9 +145,9 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
options.defaultValues.map(item => {
return {
key: item[options.valueProp || 'key'] || item,
value: item[options.labelProp || 'value'] || item
value: item[options.labelProp || 'value'] || item,
};
})
}),
);
} else {
this.options$ = of([]);
@ -110,7 +156,6 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
writeValue(value: any[]): void {
this.value.setValue(value || '');
this.changeDetectorRef.markForCheck();
}
registerOnChange(fn: any): void {
@ -127,40 +172,8 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
} else {
this.value.enable();
}
this.changeDetectorRef.markForCheck();
}
get isInvalid(): boolean {
if (this.control) {
return this.control.invalid && (this.control.dirty || this.control.touched);
}
return false;
}
get errors(): string[] {
if (!this.control?.errors) return [];
if (this.control && this.control.errors) {
const errorKeys = Object.keys(this.control.errors);
const validators = this.field().validators || [];
return errorKeys.map(key => {
const validator = validators.find(
v => v.type.toLowerCase() === key.toLowerCase(),
);
if (validator && validator.message) {
return validator.message;
}
// Fallback error messages
if (key === 'required') return `${this.field().label} is required`;
if (key === 'email') return 'Please enter a valid email address';
if (key === 'minlength')
return `Minimum length is ${this.control.errors[key].requiredLength}`;
if (key === 'maxlength')
return `Maximum length is ${this.control.errors[key].requiredLength}`;
return `${this.field().label} is invalid due to ${key} validation.`;
});
}
return [];
}
get value() {
return this.fieldFormGroup.get('value');
}
@ -175,6 +188,6 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
}
}
private onChange: (value: any) => void = () => { };
private onTouched: () => void = () => { };
private onChange: (value: any) => void = () => {};
private onTouched: () => void = () => {};
}

20
npm/ng-packs/packages/components/dynamic-form/src/dynamic-form.component.ts

@ -6,7 +6,7 @@ import {
inject,
OnInit,
DestroyRef,
ChangeDetectorRef,
signal,
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@ -43,10 +43,9 @@ export class DynamicFormComponent implements OnInit {
formCancel = output<void>();
private dynamicFormService = inject(DynamicFormService);
readonly destroyRef = inject(DestroyRef);
readonly changeDetectorRef = inject(ChangeDetectorRef);
dynamicForm!: FormGroup;
fieldVisibility: { [key: string]: boolean } = {};
readonly fieldVisibility = signal<Record<string, boolean>>({});
ngOnInit() {
this.setupFormAndLogic();
@ -74,7 +73,7 @@ export class DynamicFormComponent implements OnInit {
}
isFieldVisible(field: FormFieldConfig): boolean {
return this.fieldVisibility[field.key] !== false;
return this.fieldVisibility()[field.key] !== false;
}
getChildFormGroup(key: string): FormGroup {
@ -88,16 +87,14 @@ export class DynamicFormComponent implements OnInit {
this.dynamicForm.reset({ ...initialValues });
this.dynamicForm.markAsUntouched();
this.dynamicForm.markAsPristine();
this.changeDetectorRef.markForCheck();
}
private initializeFieldVisibility() {
const visibility: Record<string, boolean> = {};
this.fields().forEach(field => {
this.fieldVisibility = {
...this.fieldVisibility,
[field.key]: !field.conditionalLogic?.length,
};
visibility[field.key] = !field.conditionalLogic?.length;
});
this.fieldVisibility.set(visibility);
}
private setupConditionalLogic() {
@ -152,10 +149,10 @@ export class DynamicFormComponent implements OnInit {
switch (action) {
case ConditionalAction.SHOW:
this.fieldVisibility = { ...this.fieldVisibility, [fieldKey]: shouldApply };
this.fieldVisibility.update(visibility => ({ ...visibility, [fieldKey]: shouldApply }));
break;
case ConditionalAction.HIDE:
this.fieldVisibility = { ...this.fieldVisibility, [fieldKey]: !shouldApply };
this.fieldVisibility.update(visibility => ({ ...visibility, [fieldKey]: !shouldApply }));
break;
case ConditionalAction.ENABLE:
if (control) {
@ -174,7 +171,6 @@ export class DynamicFormComponent implements OnInit {
this.dynamicForm = this.dynamicFormService.createFormGroup(this.fields());
this.initializeFieldVisibility();
this.setupConditionalLogic();
this.changeDetectorRef.markForCheck();
}
private markAllFieldsAsTouched() {

43
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.html

@ -1,11 +1,11 @@
<ng-container *abpPermission="prop().permission; runChangeDetection: false">
@switch (getComponent(prop())) {
@case ('template') {
<ng-container *ngComponentOutlet="prop().template; injector: injectorForCustomComponent" />
<ng-container *ngComponentOutlet="prop().template; injector: injectorForCustomComponent()" />
}
}
<div [class]="containerClassName" class="mb-2">
<div [class]="containerClassName()" class="mb-2">
@switch (getComponent(prop())) {
@case ('input') {
<ng-template [ngTemplateOutlet]="label" />
@ -15,8 +15,8 @@
[formControlName]="prop().name"
[autocomplete]="prop().autocomplete"
[type]="getType(prop())"
[abpDisabled]="disabled"
[readonly]="readonly"
[abpDisabled]="disabled()"
[readonly]="isReadonly()"
class="form-control"
/>
}
@ -29,7 +29,7 @@
#field
[id]="prop().id"
[formControlName]="prop().name"
[abpDisabled]="disabled"
[abpDisabled]="disabled()"
type="checkbox"
class="form-check-input"
/>
@ -45,10 +45,10 @@
#field
[id]="prop().id"
[formControlName]="prop().name"
[abpDisabled]="disabled"
[abpDisabled]="disabled()"
class="form-select form-control"
>
@for (option of options$ | async; track option.value) {
@for (option of options$() | async; track option.value) {
<option [ngValue]="option.value">
@if (prop().isExtra) {
{{ '::' + option.key | abpLocalization }}
@ -63,9 +63,9 @@
<ng-template [ngTemplateOutlet]="label"></ng-template>
<abp-extensible-form-multi-select
[prop]="prop()"
[options]="options$ | async"
[options]="options$() | async"
[formControlName]="prop().name"
[abpDisabled]="disabled"
[abpDisabled]="disabled()"
/>
}
@case ('typeahead') {
@ -75,15 +75,16 @@
#field
[id]="prop().id"
[autocomplete]="prop().autocomplete"
[abpDisabled]="disabled"
[abpDisabled]="disabled()"
[ngbTypeahead]="search"
[editable]="false"
[inputFormatter]="typeaheadFormatter"
[resultFormatter]="typeaheadFormatter"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="typeaheadModel"
[ngModel]="typeaheadModel()"
(ngModelChange)="typeaheadModel.set($event)"
(selectItem)="setTypeaheadValue($event.item)"
(blur)="setTypeaheadValue(typeaheadModel)"
(blur)="setTypeaheadValue(typeaheadModel())"
[class.is-invalid]="typeahead.classList.contains('is-invalid')"
class="form-control"
/>
@ -117,8 +118,8 @@
#field
[id]="prop().id"
[formControlName]="prop().name"
[abpDisabled]="disabled"
[readonly]="readonly"
[abpDisabled]="disabled()"
[readonly]="isReadonly()"
class="form-control"
></textarea>
}
@ -129,15 +130,19 @@
class="form-control"
[id]="prop().id"
[formControlName]="prop().name"
[abpShowPassword]="showPassword"
[abpShowPassword]="showPassword()"
/>
<button class="btn btn-secondary" type="button" (click)="showPassword = !showPassword">
<button
class="btn btn-secondary"
type="button"
(click)="showPassword.update(value => !value)"
>
<i
class="fa"
aria-hidden="true"
[class]="{
'fa-eye-slash': !showPassword,
'fa-eye': showPassword,
'fa-eye-slash': !showPassword(),
'fa-eye': showPassword(),
}"
></i>
</button>
@ -163,7 +168,7 @@
{{ prop().displayName | abpLocalization }}
}
}
{{ asterisk }}
{{ asterisk() }}
@if (prop().tooltip) {
<i
[ngbTooltip]="prop().tooltip.text | abpLocalization"

99
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.ts

@ -9,8 +9,8 @@ import {
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
ElementRef,
inject,
Injector,
@ -19,6 +19,7 @@ import {
viewChild,
effect,
input,
signal,
} from '@angular/core';
import {
ControlContainer,
@ -85,7 +86,6 @@ import { ExtensibleFormMultiselectComponent } from '../multi-select/extensible-f
})
export class ExtensibleFormPropComponent implements AfterViewInit {
protected service = inject(ExtensibleFormPropService);
public readonly cdRef = inject(ChangeDetectorRef);
public readonly track = inject(TrackByService);
#groupDirective = inject(FormGroupDirective);
private injector = inject(Injector);
@ -97,26 +97,24 @@ export class ExtensibleFormPropComponent implements AfterViewInit {
readonly isFirstGroup = input<boolean | undefined>(undefined);
private readonly fieldRef = viewChild.required<ElementRef<HTMLElement>>('field');
injectorForCustomComponent?: Injector;
asterisk = '';
containerClassName = 'mb-2';
showPassword = false;
options$: Observable<ABP.Option<any>[]> = of([]);
validators: ValidatorFn[] = [];
readonly!: boolean;
typeaheadModel: any;
readonly injectorForCustomComponent = signal<Injector | undefined>(undefined);
readonly asterisk = signal('');
readonly containerClassName = signal('mb-2');
readonly showPassword = signal(false);
readonly options$ = signal<Observable<ABP.Option<any>[]>>(of([]));
readonly validators = signal<ValidatorFn[]>([]);
readonly isReadonly = signal(false);
readonly typeaheadModel = signal<any>(undefined);
passwordKey = eExtensibleComponents.PasswordComponent;
readonly disabledFn = signal<(data: ReadonlyPropData) => boolean>(() => false);
disabledFn = (data: ReadonlyPropData) => false;
get disabled() {
readonly disabled = computed(() => {
const data = this.data();
if (!data) return false;
return this.disabledFn(data);
}
return this.disabledFn()(data);
});
constructor() {
// Watch prop changes and update state
effect(() => {
const currentProp = this.prop();
const data = this.data();
@ -125,47 +123,60 @@ export class ExtensibleFormPropComponent implements AfterViewInit {
const { options, readonly, disabled, validators, className, template } = currentProp;
if (template) {
this.injectorForCustomComponent = Injector.create({
providers: [
{
provide: EXTENSIONS_FORM_PROP,
useValue: currentProp,
},
{
provide: EXTENSIONS_FORM_PROP_DATA,
useValue: data?.record,
},
{ provide: ControlContainer, useExisting: FormGroupDirective },
],
parent: this.injector,
});
this.injectorForCustomComponent.set(
Injector.create({
providers: [
{
provide: EXTENSIONS_FORM_PROP,
useValue: currentProp,
},
{
provide: EXTENSIONS_FORM_PROP_DATA,
useValue: data?.record,
},
{ provide: ControlContainer, useExisting: FormGroupDirective },
],
parent: this.injector,
}),
);
} else {
this.injectorForCustomComponent.set(undefined);
}
if (options) this.options$ = options(data);
if (readonly) this.readonly = readonly(data);
if (options) this.options$.set(options(data));
if (readonly) this.isReadonly.set(readonly(data));
if (disabled) {
this.disabledFn = disabled;
this.disabledFn.set(disabled);
} else {
this.disabledFn.set(() => false);
}
if (validators) {
this.validators = validators(data);
this.setAsterisk();
this.validators.set(validators(data));
this.asterisk.set(this.service.calcAsterisks(validators(data)));
} else {
this.validators.set([]);
this.asterisk.set('');
}
if (className !== undefined) {
this.containerClassName = className;
this.containerClassName.set(className);
} else {
this.containerClassName.set('mb-2');
}
const [keyControl, valueControl] = this.getTypeaheadControls();
if (keyControl && valueControl)
this.typeaheadModel = { key: keyControl.value, value: valueControl.value };
this.cdRef.markForCheck();
if (keyControl && valueControl) {
this.typeaheadModel.set({ key: keyControl.value, value: valueControl.value });
}
});
}
setTypeaheadValue(selectedOption: ABP.Option<string>) {
this.typeaheadModel = selectedOption || { key: null, value: null };
const { key, value } = this.typeaheadModel;
const model = selectedOption || { key: null, value: null };
this.typeaheadModel.set(model);
const { key, value } = model;
const [keyControl, valueControl] = this.getTypeaheadControls();
if (valueControl?.value && !value) valueControl.markAsDirty();
keyControl?.setValue(key);
@ -200,10 +211,6 @@ export class ExtensibleFormPropComponent implements AfterViewInit {
return [keyControl, valueControl];
}
private setAsterisk() {
this.asterisk = this.service.calcAsterisks(this.validators);
}
ngAfterViewInit() {
if (this.isFirstGroup() && this.first() && this.fieldRef()) {
requestAnimationFrame(() => {

2
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.ts

@ -1,7 +1,6 @@
import { TrackByService } from '@abp/ng.core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Optional,
@ -36,7 +35,6 @@ import { PropDataDirective } from '../../directives/prop-data.directive';
],
})
export class ExtensibleFormComponent<R = any> {
public readonly cdRef = inject(ChangeDetectorRef);
public readonly track = inject(TrackByService);
private readonly container = inject(ControlContainer);
private readonly extensions = inject(ExtensionsService);

3
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table-row-detail/extensible-table-row-detail.component.ts

@ -1,6 +1,7 @@
import { Component, contentChild, input, TemplateRef } from '@angular/core';
import {Component, contentChild, input, TemplateRef, ChangeDetectionStrategy,} from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-extensible-table-row-detail',
template: '',
})

45
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html

@ -16,14 +16,26 @@
[footerHeight]="infiniteScroll() ? false : 50"
>
@if (effectiveRowDetailTemplate) {
<ngx-datatable-row-detail [rowHeight]="effectiveRowDetailHeight">
<ng-template let-row="row" let-expanded="expanded" ngx-datatable-row-detail-template>
<ng-container
[ngTemplateOutlet]="effectiveRowDetailTemplate"
[ngTemplateOutletContext]="{ row: row, expanded: expanded }"
></ng-container>
</ng-template>
</ngx-datatable-row-detail>
@let detailRowHeight = effectiveRowDetailRowHeight();
@if (detailRowHeight !== undefined) {
<ngx-datatable-row-detail [rowHeight]="detailRowHeight">
<ng-template let-row="row" let-expanded="expanded" ngx-datatable-row-detail-template>
<ng-container
[ngTemplateOutlet]="effectiveRowDetailTemplate"
[ngTemplateOutletContext]="{ row: row, expanded: expanded }"
/>
</ng-template>
</ngx-datatable-row-detail>
} @else {
<ngx-datatable-row-detail>
<ng-template let-row="row" let-expanded="expanded" ngx-datatable-row-detail-template>
<ng-container
[ngTemplateOutlet]="effectiveRowDetailTemplate"
[ngTemplateOutletContext]="{ row: row, expanded: expanded }"
/>
</ng-template>
</ngx-datatable-row-detail>
}
<ngx-datatable-column
[width]="50"
@ -54,7 +66,6 @@
>
<ng-template
ngx-datatable-header-template
let-value="value"
let-allRowsSelected="allRowsSelected"
let-selectFn="selectFn"
>
@ -64,7 +75,7 @@
class="form-check-input table-check"
type="checkbox"
[checked]="allRowsSelected"
(change)="selectFn(!allRowsSelected)"
(change)="selectFn()"
/>
</div>
}
@ -103,8 +114,8 @@
@if (actionsTemplate() || (actionList.length && hasAtLeastOnePermittedAction)) {
<ngx-datatable-column
[name]="actionsText() | abpLocalization"
[maxWidth]="_actionsColumnWidth() ?? undefined"
[width]="_actionsColumnWidth() ?? 200"
[maxWidth]="_actionsColumnWidth()"
[width]="_actionsColumnWidth() || 200"
[canAutoResize]="!_actionsColumnWidth()"
[sortable]="false"
>
@ -123,7 +134,7 @@
@for (prop of propList; track prop.name; let i = $index) {
<ngx-datatable-column
*abpVisible="prop.columnVisible(getInjected)"
[width]="columnWidths()[i] ?? 200"
[width]="columnWidths()[i] || 200"
[canAutoResize]="!columnWidths()[i]"
[name]="(prop.isExtra ? '::' + prop.displayName : prop.displayName) | abpLocalization"
[prop]="prop.name"
@ -136,17 +147,17 @@
[placement]="prop.tooltip.placement || 'auto'"
container="body"
[class.pointer]="prop.sortable"
(click)="prop.sortable && sortFn(column)"
(click)="prop.sortable && sortFn()"
>
{{ column.name }} <i class="fa fa-info-circle" aria-hidden="true"></i>
</span>
} @else {
<span [class.pointer]="prop.sortable" (click)="prop.sortable && sortFn(column)">
<span [class.pointer]="prop.sortable" (click)="prop.sortable && sortFn()">
{{ column.name }}
</span>
}
</ng-template>
<ng-template let-row="row" let-i="index" ngx-datatable-cell-template>
<ng-template let-row="row" let-i="rowIndex" ngx-datatable-cell-template>
<ng-container *abpPermission="prop.permission; runChangeDetection: false">
<ng-container *abpVisible="row['_' + prop.name]?.visible">
@if (!row['_' + prop.name].component) {
@ -186,7 +197,7 @@
row['_' + prop.name].component;
injector: row['_' + prop.name].injector
"
></ng-container>
/>
}
</ng-container>
</ng-container>

49
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts

@ -1,7 +1,6 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
inject,
@ -23,7 +22,12 @@ import { AsyncPipe, isPlatformBrowser, NgComponentOutlet, NgTemplateOutlet } fro
import { Observable, filter, map, Subject, debounceTime, distinctUntilChanged } from 'rxjs';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { NgxDatatableModule, SelectionType, DatatableComponent } from '@swimlane/ngx-datatable';
import {
NgxDatatableModule,
SelectionType,
DatatableComponent,
ScrollEvent,
} from '@swimlane/ngx-datatable';
import {
ABP,
@ -89,7 +93,6 @@ const DEFAULT_ACTIONS_COLUMN_WIDTH = 150;
export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestroy {
readonly #injector = inject(Injector);
readonly getInjected = this.#injector.get.bind(this.#injector);
protected readonly cdr = inject(ChangeDetectorRef);
protected readonly locale = inject(LOCALE_ID);
protected readonly config = inject(ConfigStateService);
protected readonly timeZoneService = inject(TimezoneService);
@ -130,7 +133,7 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
// Internal signals
protected readonly _data = signal<R[]>([]);
private readonly _actionsColumnWidth = signal<number | undefined>(DEFAULT_ACTIONS_COLUMN_WIDTH);
protected readonly _actionsColumnWidth = signal<number | undefined>(DEFAULT_ACTIONS_COLUMN_WIDTH);
readonly rowDetailComponent = contentChild(ExtensibleTableRowDetailComponent);
@ -162,6 +165,27 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
return this.rowDetailComponent()?.rowHeight() ?? this.rowDetailHeight();
}
protected readonly effectiveRowDetailRowHeight = computed(
(): number | ((row?: R, index?: number) => number) | undefined => {
const height = this.effectiveRowDetailHeight;
if (typeof height === 'number') {
return height as number;
}
if (typeof height === 'function') {
return height as (row?: R, index?: number) => number;
}
if (typeof height === 'string' && !height.endsWith('%')) {
const parsed = Number.parseInt(height, 10);
return Number.isNaN(parsed) ? undefined : parsed;
}
return undefined;
},
);
hasAtLeastOnePermittedAction: boolean;
readonly propList: EntityPropList<R>;
@ -299,17 +323,16 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
this.selectionChange.emit(selected);
}
onScroll(scrollEvent: Event): void {
onScroll(scrollEvent: ScrollEvent): void {
if (!this.shouldHandleScroll()) {
return;
}
const target = scrollEvent.target as HTMLElement;
if (!target) {
return;
}
const table = this.table();
const viewportHeight = table.bodyHeight;
const scrollHeight = table.bodyComponent.scrollHeight();
if (this.isNearScrollBottom(target)) {
if (scrollEvent.offsetY + viewportHeight >= scrollHeight - this.scrollThreshold()) {
this.loadMoreSubject.next();
}
}
@ -318,11 +341,6 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
return this.infiniteScroll() && !this.isLoading();
}
private isNearScrollBottom(element: HTMLElement): boolean {
const { offsetHeight, scrollTop, scrollHeight } = element;
return offsetHeight + scrollTop >= scrollHeight - this.scrollThreshold();
}
private triggerLoadMore(): void {
this.loadMore.emit();
}
@ -348,7 +366,6 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
?.requestStatus$?.pipe(filter(status => status === 'loading'))
.subscribe(() => {
this._data.set([]);
this.cdr.markForCheck();
});
}
}

2
npm/ng-packs/packages/components/package.json

@ -12,7 +12,7 @@
},
"dependencies": {
"chart.js": "^3.5.1",
"ng-zorro-antd": "~21.0.0-next.1",
"ng-zorro-antd": "~21.3.1",
"@ctrl/tinycolor": "^4.0.0",
"tslib": "^2.0.0"
},

3
npm/ng-packs/packages/components/page/src/page-parts.component.ts

@ -1,4 +1,4 @@
import { Component, ViewEncapsulation } from '@angular/core';
import { Component, ViewEncapsulation, ChangeDetectionStrategy } from '@angular/core';
export enum PageParts {
title = 'PageTitleContainerComponent',
@ -7,6 +7,7 @@ export enum PageParts {
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-page-title-container',
template: ` <ng-content></ng-content> `,
encapsulation: ViewEncapsulation.None,

11
npm/ng-packs/packages/components/page/src/page.component.ts

@ -1,4 +1,12 @@
import { Component, ViewEncapsulation, input, effect, signal, contentChild } from '@angular/core';
import {
Component,
ViewEncapsulation,
input,
effect,
signal,
contentChild,
ChangeDetectionStrategy,
} from '@angular/core';
import {
PageTitleContainerComponent,
PageBreadcrumbContainerComponent,
@ -10,6 +18,7 @@ import { PageToolbarComponent } from '@abp/ng.components/extensible';
import { PagePartDirective } from './page-part.directive';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-page',
templateUrl: './page.component.html',
encapsulation: ViewEncapsulation.None,

2
npm/ng-packs/packages/components/project.json

@ -31,7 +31,7 @@
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../coverage/packages/components"
"reportsDirectory": "{projectRoot}/../../coverage/packages/components"
}
}
}

57
npm/ng-packs/packages/components/tree/src/lib/components/tree.component.ts

@ -1,6 +1,5 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
contentChild,
inject,
@ -10,7 +9,7 @@ import {
input,
output,
signal,
effect
effect,
} from '@angular/core';
import { NgbDropdown, NgbDropdownMenu, NgbDropdownToggle } from '@ng-bootstrap/ng-bootstrap';
import {
@ -54,7 +53,6 @@ export type DropEvent = NzFormatEmitEvent & { pos: number };
export class TreeComponent implements OnInit {
private lazyLoadService = inject(LazyLoadService);
private subscriptionService = inject(SubscriptionService);
private cdr = inject(ChangeDetectorRef);
private disableTreeStyleLoading = inject(DISABLE_TREE_STYLE_LOADING_TOKEN, { optional: true });
dropPosition!: number;
@ -69,7 +67,7 @@ export class TreeComponent implements OnInit {
readonly selectedNodeChange = output<any>();
readonly dropOver = output<DropEvent>();
readonly nzExpandChange = output<NzFormatEmitEvent>();
// Input signals
readonly noAnimation = input(true);
readonly draggable = input<boolean | undefined>(undefined);
@ -81,15 +79,15 @@ export class TreeComponent implements OnInit {
readonly selectedNodeInput = input<any>(undefined, { alias: 'selectedNode' });
readonly changeCheckboxWithNode = input<boolean | undefined>(undefined);
readonly isNodeSelectedFn = input<(node: any) => boolean>(
(node) => this._selectedNode()?.id === node.key,
{ alias: 'isNodeSelected' }
node => this._selectedNode()?.id === node.key,
{ alias: 'isNodeSelected' },
);
readonly beforeDropFn = input<(event: NzFormatBeforeDropEvent) => any>(
(event: NzFormatBeforeDropEvent) => {
this.dropPosition = event.pos;
return of(false);
},
{ alias: 'beforeDrop' }
{ alias: 'beforeDrop' },
);
// Internal signals for two-way binding
@ -99,12 +97,24 @@ export class TreeComponent implements OnInit {
protected readonly _nodes = signal<any[]>([]);
// Getters for template access
get checkedKeys() { return this._checkedKeys(); }
get expandedKeys() { return this._expandedKeys(); }
get selectedNode() { return this._selectedNode(); }
get nodes() { return this._nodes(); }
get isNodeSelected() { return this.isNodeSelectedFn(); }
get beforeDrop() { return this.beforeDropFn(); }
get checkedKeys() {
return this._checkedKeys();
}
get expandedKeys() {
return this._expandedKeys();
}
get selectedNode() {
return this._selectedNode();
}
get nodes() {
return this._nodes();
}
get isNodeSelected() {
return this.isNodeSelectedFn();
}
get beforeDrop() {
return this.beforeDropFn();
}
constructor() {
// Sync input signals to internal signals
@ -163,23 +173,25 @@ export class TreeComponent implements OnInit {
}
}
onCheckboxChange(event: { keys: any[] }) {
this._checkedKeys.set([...event.keys]);
this.checkedKeysChange.emit(event.keys);
onCheckboxChange(event: NzFormatEmitEvent) {
this._checkedKeys.set([...(event.keys as any[])]);
this.checkedKeysChange.emit(event.keys as any[]);
}
onExpandedKeysChange(event: { keys: string[] } & NzFormatEmitEvent) {
this._expandedKeys.set([...event.keys]);
this.expandedKeysChange.emit(event.keys);
onExpandedKeysChange(event: NzFormatEmitEvent) {
this._expandedKeys.set([...(event.keys as string[])]);
this.expandedKeysChange.emit(event.keys as string[]);
this.nzExpandChange.emit(event);
}
onDrop(event: DropEvent) {
onDrop(event: NzFormatEmitEvent) {
event.event?.stopPropagation();
event.event?.preventDefault();
event.pos = this.dropPosition;
this.dropOver.emit(event);
this.dropOver.emit({
...event,
pos: this.dropPosition,
});
}
initDropdown(key: string, dropdown: NgbDropdown) {
@ -202,6 +214,5 @@ export class TreeComponent implements OnInit {
setSelectedNode(node: any) {
const newSelectedNode = this.findNode(node, this._nodes());
this._selectedNode.set({ ...newSelectedNode });
this.cdr.markForCheck();
}
}

4
npm/ng-packs/packages/components/tsconfig.lib.json

@ -12,7 +12,7 @@
},
"exclude": [
"src/test-setup.ts",
"src/**/*.spec.ts",
"**/*.spec.ts",
"jest.config.ts",
"vite.config.ts",
"vite.config.mts",
@ -27,5 +27,5 @@
"src/**/*.spec.jsx",
"src/test-setup.ts"
],
"include": ["src/**/*.ts"]
"include": ["**/*.ts"]
}

2
npm/ng-packs/packages/core/project.json

@ -30,7 +30,7 @@
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../coverage/packages/core"
"reportsDirectory": "{projectRoot}/../../coverage/packages/core"
}
}
}

3
npm/ng-packs/packages/core/src/lib/abstracts/ng-model.component.ts

@ -1,8 +1,9 @@
import { ChangeDetectorRef, Component, inject, input } from '@angular/core';
import {ChangeDetectorRef, Component, inject, input, ChangeDetectionStrategy,} from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
// Not an abstract class on purpose. Do not change!
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: '',
})
export class AbstractNgModelComponent<T = any, U = T> implements ControlValueAccessor {

68
npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts

@ -1,42 +1,40 @@
import {
Component,
inject,
input,
isDevMode,
Type,
} from '@angular/core';
import {Component, inject, input, isDevMode, Type, ChangeDetectionStrategy, signal,} from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { startWith } from 'rxjs/operators';
import { eLayoutType } from '../enums/common';
import { ABP } from '../models';
import { ReplaceableComponents } from '../models/replaceable-components';
import { LocalizationService } from '../services/localization.service';
import { ReplaceableComponentsService } from '../services/replaceable-components.service';
import { RouterEvents } from '../services/router-events.service';
import { RoutesService } from '../services/routes.service';
import { SubscriptionService } from '../services/subscription.service';
import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service';
import { findRoute } from '../utils/route-utils';
import { TreeNode } from '../utils/tree-utils';
import { DYNAMIC_LAYOUTS_TOKEN } from '../tokens/dynamic-layout.token';
import { EnvironmentService } from '../services';
import { NgComponentOutlet } from '@angular/common';
import { filter, take } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-dynamic-layout',
template: `
@if (isLayoutVisible) {
<ng-container [ngComponentOutlet]="layout"></ng-container>
@if (isLayoutVisible()) {
<ng-container [ngComponentOutlet]="layout()" />
}
`,
providers: [SubscriptionService],
imports: [NgComponentOutlet],
})
export class DynamicLayoutComponent {
layout?: Type<any>;
layoutKey?: eLayoutType;
readonly layout = signal<Type<any> | undefined>(undefined);
readonly layoutKey = signal<eLayoutType | undefined>(undefined);
readonly layouts = inject(DYNAMIC_LAYOUTS_TOKEN);
isLayoutVisible = true;
readonly isLayoutVisible = signal(true);
readonly defaultLayout = input<eLayoutType>(undefined);
protected readonly router = inject(Router);
@ -46,24 +44,25 @@ export class DynamicLayoutComponent {
protected readonly replaceableComponents = inject(ReplaceableComponentsService);
protected readonly subscription = inject(SubscriptionService);
protected readonly routerEvents = inject(RouterEvents);
protected readonly environment = inject(EnvironmentService);
protected readonly routeCultureUrl = inject(RouteBasedCultureUrlService);
constructor() {
const dynamicLayoutComponent = inject(DynamicLayoutComponent, { optional: true, skipSelf: true });
const dynamicLayoutComponent = inject(DynamicLayoutComponent, {
optional: true,
skipSelf: true,
});
if (dynamicLayoutComponent) {
if (isDevMode()) console.warn('DynamicLayoutComponent must be used only in AppComponent.');
return;
}
this.checkLayoutOnNavigationEnd();
this.listenToLayoutChanges();
this.listenToLanguageChange();
this.listenToEnvironmentChange();
}
private checkLayoutOnNavigationEnd() {
private listenToLayoutChanges() {
const navigationEnd$ = this.routerEvents.getNavigationEvents('End');
this.subscription.addOne(navigationEnd$, () => this.getLayout());
this.subscription.addOne(navigationEnd$.pipe(startWith(null)), () => this.getLayout());
}
private getLayout() {
@ -71,14 +70,14 @@ export class DynamicLayoutComponent {
if (!expectedLayout) expectedLayout = eLayoutType.empty;
if (this.layoutKey === expectedLayout) return;
if (this.layoutKey() === expectedLayout) return;
const key = this.layouts.get(expectedLayout);
if (key) {
this.layout = this.getComponent(key)?.component;
this.layoutKey = expectedLayout;
this.layout.set(this.getComponent(key)?.component);
this.layoutKey.set(expectedLayout);
}
if (!this.layout) {
if (!this.layout()) {
this.showLayoutNotFoundError(expectedLayout);
}
}
@ -112,27 +111,12 @@ export class DynamicLayoutComponent {
private listenToLanguageChange() {
this.subscription.addOne(this.localizationService.languageChange$, () => {
this.isLayoutVisible = false;
setTimeout(() => (this.isLayoutVisible = true), 0);
this.isLayoutVisible.set(false);
setTimeout(() => this.isLayoutVisible.set(true), 0);
});
}
private getComponent(key: string): ReplaceableComponents.ReplaceableComponent | undefined {
return this.replaceableComponents.get(key);
}
private listenToEnvironmentChange() {
this.environment
.createOnUpdateStream(x => x.oAuthConfig)
.pipe(
take(1),
filter(config => config.responseType === 'code'),
)
.subscribe(() => {
if (this.layout) {
return;
}
this.getLayout();
});
}
}

11
npm/ng-packs/packages/core/src/lib/components/replaceable-route-container.component.ts

@ -1,4 +1,4 @@
import { Component, OnInit, Type, inject } from '@angular/core';
import { Component, OnInit, Type, inject, ChangeDetectionStrategy, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { distinctUntilChanged } from 'rxjs/operators';
import { ReplaceableComponents } from '../models/replaceable-components';
@ -7,10 +7,9 @@ import { SubscriptionService } from '../services/subscription.service';
import { NgComponentOutlet } from '@angular/common';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-replaceable-route-container',
template: `
<ng-container *ngComponentOutlet="externalComponent || defaultComponent"></ng-container>
`,
template: ` <ng-container *ngComponentOutlet="externalComponent() || defaultComponent" /> `,
providers: [SubscriptionService],
imports: [NgComponentOutlet],
})
@ -23,7 +22,7 @@ export class ReplaceableRouteContainerComponent implements OnInit {
componentKey!: string;
externalComponent?: Type<any>;
readonly externalComponent = signal<Type<any> | undefined>(undefined);
ngOnInit() {
this.defaultComponent = this.route.snapshot.data.replaceableComponent.defaultComponent;
@ -38,7 +37,7 @@ export class ReplaceableRouteContainerComponent implements OnInit {
this.subscription.addOne(
component$,
(res = {} as ReplaceableComponents.ReplaceableComponent) => {
this.externalComponent = res.component;
this.externalComponent.set(res.component);
},
);
}

3
npm/ng-packs/packages/core/src/lib/components/router-outlet.component.ts

@ -1,7 +1,8 @@
import { Component } from '@angular/core';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-router-outlet',
template: ` <router-outlet></router-outlet> `,
imports: [RouterOutlet],

10
npm/ng-packs/packages/core/src/lib/guards/permission.guard.ts

@ -34,7 +34,10 @@ export class PermissionGuard implements IAbpGuard {
protected readonly configStateService = inject(ConfigStateService);
protected readonly routeCultureUrl = inject(RouteBasedCultureUrlService);
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): Observable<boolean | UrlTree> {
let { requiredPolicy } = route.data || {};
if (!requiredPolicy) {
@ -86,7 +89,10 @@ export const permissionGuard: CanActivateFn = (
let { requiredPolicy } = route.data || {};
if (!requiredPolicy) {
const routeFound = findRoute(routesService, routeCultureUrl.getRoutePathForMatching(router, state.url));
const routeFound = findRoute(
routesService,
routeCultureUrl.getRoutePathForMatching(router, state.url),
);
requiredPolicy = routeFound?.requiredPolicy;
}

4
npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts

@ -25,7 +25,7 @@ export class AbpTitleStrategy extends TitleStrategy {
});
}
override updateTitle(routerState: RouterStateSnapshot) {
override updateTitle(routerState: RouterStateSnapshot): void {
this.routerState = routerState;
if (!routerState?.root) {
return;
@ -41,7 +41,7 @@ export class AbpTitleStrategy extends TitleStrategy {
if (!title) {
return this.title.setTitle(projectName);
}
let localizedText = this.localizationService.instant({ key: title, defaultValue: title });
if (!this.disableProjectName) {
localizedText += ` | ${projectName}`;

31
npm/ng-packs/packages/core/src/lib/strategies/projection.strategy.ts

@ -1,7 +1,7 @@
import {
ApplicationRef,
ComponentFactoryResolver,
ComponentRef,
createComponent,
EmbeddedViewRef,
Injector,
TemplateRef,
@ -31,14 +31,10 @@ export class ComponentProjectionStrategy<T extends Type<any>> extends Projection
injectContent(injector: Injector) {
this.containerStrategy.prepare();
const resolver = injector.get(ComponentFactoryResolver) as ComponentFactoryResolver;
const factory = resolver.resolveComponentFactory<InferredInstanceOf<T>>(this.content);
const componentRef = this.containerStrategy.containerRef.createComponent(
factory,
this.containerStrategy.getIndex(),
const componentRef = this.containerStrategy.containerRef.createComponent(this.content, {
index: this.containerStrategy.getIndex(),
injector,
);
});
this.contextStrategy.setContext(componentRef);
return componentRef as ComponentRefOrEmbeddedViewRef<T>;
@ -56,10 +52,10 @@ export class RootComponentProjectionStrategy<T extends Type<any>> extends Projec
injectContent(injector: Injector) {
const appRef = injector.get(ApplicationRef);
const resolver = injector.get(ComponentFactoryResolver) as ComponentFactoryResolver;
const componentRef = resolver
.resolveComponentFactory<InferredInstanceOf<T>>(this.content)
.create(injector);
const componentRef = createComponent(this.content, {
environmentInjector: appRef.injector,
elementInjector: injector,
});
this.contextStrategy.setContext(componentRef);
@ -172,8 +168,9 @@ export const PROJECTION_STRATEGY = {
},
};
type ComponentRefOrEmbeddedViewRef<T> = T extends Type<infer U>
? ComponentRef<U>
: T extends TemplateRef<infer C>
? EmbeddedViewRef<C>
: never;
type ComponentRefOrEmbeddedViewRef<T> =
T extends Type<infer U>
? ComponentRef<U>
: T extends TemplateRef<infer C>
? EmbeddedViewRef<C>
: never;

4
npm/ng-packs/packages/core/testing/src/lib/core-testing.module.ts

@ -15,7 +15,7 @@ import {
import { APP_BASE_HREF } from '@angular/common';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { provideRoutes } from '@angular/router';
import { provideRouter } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { MockPermissionService } from './services/mock-permission.service';
import { MockRestService } from './services/mock-rest.service';
@ -77,7 +77,7 @@ export class CoreTestingModule {
provide: SORT_COMPARE_FUNC,
useFactory: compareFuncFactory,
},
provideRoutes(routes),
provideRouter(routes),
],
};
}

2
npm/ng-packs/packages/feature-management/project.json

@ -31,7 +31,7 @@
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../coverage/packages/feature-management"
"reportsDirectory": "{projectRoot}/../../coverage/packages/feature-management"
}
}
}

3
npm/ng-packs/packages/feature-management/src/lib/components/feature-management-tab/feature-management-tab.component.ts

@ -1,8 +1,9 @@
import { LocalizationPipe, ReplaceableTemplateDirective } from '@abp/ng.core';
import { Component } from '@angular/core';
import {Component, ChangeDetectionStrategy,} from '@angular/core';
import { FeatureManagementComponent } from '../feature-management/feature-management.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-feature-management-tab',
templateUrl: './feature-management-tab.component.html',
imports: [ReplaceableTemplateDirective, LocalizationPipe, FeatureManagementComponent],

234
npm/ng-packs/packages/feature-management/src/lib/components/feature-management/feature-management.component.html

@ -1,22 +1,35 @@
@if (visible) {
<abp-modal [(visible)]="visible" [busy]="modalBusy" [options]="{ size: 'lg' }">
<ng-template #abpHeader>
<h3>
{{ 'AbpFeatureManagement::Features' | abpLocalization }}
@if (providerTitle()) {
- {{ providerTitle() }}
}
</h3>
</ng-template>
<abp-modal [(visible)]="visible" [busy]="modalBusy()" [options]="{ size: 'lg' }">
<ng-template #abpHeader>
<h3>
{{ 'AbpFeatureManagement::Features' | abpLocalization }}
@if (providerTitle()) {
- {{ providerTitle() }}
}
</h3>
</ng-template>
<ng-template #abpBody>
<div class="row">
@if (groups.length) {
@if (groups().length) {
<div ngTabs orientation="vertical" class="row">
<div class="col-md-4">
<div ngTabList orientation="vertical" selectionMode="follow" [(selectedTab)]="selectedGroupDisplayName" class="nav nav-pills flex-column">
@for (group of groups; track group.name) {
<button ngTab #tab="ngTab" type="button" [value]="group.displayName" class="nav-link text-start" [class.active]="tab.selected()">
<div
ngTabList
orientation="vertical"
selectionMode="follow"
[(selectedTab)]="selectedGroupDisplayName"
class="nav nav-pills flex-column"
>
@for (group of groups(); track group.name) {
<button
ngTab
#tab="ngTab"
type="button"
[value]="group.displayName"
class="nav-link text-start"
[class.active]="tab.selected()"
>
{{ group.displayName }}
</button>
}
@ -29,82 +42,106 @@
</ng-template>
<div class="col-md-8">
@for (group of groups; track group.name) {
@for (group of groups(); track group.name) {
<div ngTabPanel [value]="group.displayName">
<ng-template ngTabContent>
<h4>{{ selectedGroupDisplayName }}</h4>
<h4>{{ selectedGroupDisplayName() }}</h4>
<hr class="mt-2 mb-3" />
@for (feature of features[group.name]; track feature.id || i; let i = $index) {
@let provider = feature.provider.name;
@let isFeatureDisabled = !feature.parentName ? isParentDisabled(feature.name, group.name, provider) :
(provider !== providerName() && provider !== defaultProviderName);
@for (
feature of features()[group.name];
track feature.name || i;
let i = $index
) {
@let provider = feature.provider.name;
@let isFeatureDisabled =
!feature.parentName
? isParentDisabled(feature.name, group.name, provider)
: provider !== providerName() && provider !== defaultProviderName;
<div class="mt-2" [style]="feature.style" (keyup.enter)="save()">
@switch (feature.valueType?.name) {
@case (valueTypes.ToggleStringValueType) {
<div class="form-check" [class.px-4]="!!feature.parentName">
<input class="form-check-input" type="checkbox" [id]="feature.name" [(ngModel)]="feature.value"
(ngModelChange)="onCheckboxClick($event, feature)" [disabled]="isFeatureDisabled" />
<div class="mt-2" [style]="feature.style" (keyup.enter)="save()">
@switch (feature.valueType?.name) {
@case (valueTypes.ToggleStringValueType) {
<div class="form-check" [class.px-4]="!!feature.parentName">
<input
class="form-check-input"
type="checkbox"
[id]="feature.name"
[(ngModel)]="feature.value"
(ngModelChange)="onCheckboxClick($event, feature)"
[disabled]="isFeatureDisabled"
/>
<label class="form-check-label" [htmlFor]="feature.name">
{{ feature.displayName }}
@if (isFeatureDisabled) {
<span>({{ provider }})</span>
}
</label>
<ng-container *ngTemplateOutlet="
<label class="form-check-label" [htmlFor]="feature.name">
{{ feature.displayName }}
@if (isFeatureDisabled) {
<span>({{ provider }})</span>
}
</label>
<ng-container
*ngTemplateOutlet="
descTmp;
context: { $implicit: feature.description }
"></ng-container>
</div>
}
@case (valueTypes.FreeTextStringValueType) {
<div class="mb-3 form-group" [class.px-2]="!!feature.parentName">
<label [htmlFor]="feature.name" class="form-label">
{{ feature.displayName }}
@if (isFeatureDisabled) {
<span>({{ provider }})</span>
}
</label>
<input class="form-control" type="text" [id]="feature.name" [(ngModel)]="feature.value"
[abpFeatureManagementFreeText]="feature" [disabled]="isFeatureDisabled" />
"
/>
</div>
}
@case (valueTypes.FreeTextStringValueType) {
<div class="mb-3 form-group" [class.px-2]="!!feature.parentName">
<label [htmlFor]="feature.name" class="form-label">
{{ feature.displayName }}
@if (isFeatureDisabled) {
<span>({{ provider }})</span>
}
</label>
<input
class="form-control"
type="text"
[id]="feature.name"
[(ngModel)]="feature.value"
[abpFeatureManagementFreeText]="feature"
[disabled]="isFeatureDisabled"
/>
<ng-container *ngTemplateOutlet="
<ng-container
*ngTemplateOutlet="
descTmp;
context: { $implicit: feature.description }
"></ng-container>
</div>
}
@case (valueTypes.SelectionStringValueType) {
@if (feature.valueType.itemSource?.items?.length) {
<div class="mb-3 form-group" [class.px-2]="!!feature.parentName">
<label [htmlFor]="feature.name" class="form-label">
{{ feature.displayName }}
@if (isFeatureDisabled) {
<span>({{ provider }})</span>
}
</label>
<select class="form-select" [id]="feature.name" [(ngModel)]="feature.value"
[disabled]="isFeatureDisabled">
@for (
item of feature.valueType.itemSource?.items;
track item.value
) {
<option [ngValue]="item.value">
{{
item.displayText?.resourceName +
'::' +
item.displayText?.name | abpLocalization
}}
</option>
}
</select>
<ng-container *ngTemplateOutlet="
"
/>
</div>
}
@case (valueTypes.SelectionStringValueType) {
@if (getSelectionItems(feature).length) {
<div class="mb-3 form-group" [class.px-2]="!!feature.parentName">
<label [htmlFor]="feature.name" class="form-label">
{{ feature.displayName }}
@if (isFeatureDisabled) {
<span>({{ provider }})</span>
}
</label>
<select
class="form-select"
[id]="feature.name"
[(ngModel)]="feature.value"
[disabled]="isFeatureDisabled"
>
@for (item of getSelectionItems(feature); track item.value) {
<option [ngValue]="item.value">
{{
item.displayText?.resourceName +
'::' +
item.displayText?.name | abpLocalization
}}
</option>
}
</select>
<ng-container
*ngTemplateOutlet="
descTmp;
context: { $implicit: feature.description }
"
></ng-container>
/>
</div>
}
}
@ -121,7 +158,7 @@
</div>
}
@if (!groups.length) {
@if (!groups().length) {
<div class="col">
{{ 'AbpFeatureManagement::NoFeatureFoundMessage' | abpLocalization }}
</div>
@ -129,23 +166,32 @@
</div>
</ng-template>
<ng-template #abpFooter>
<button abpClose type="button" class="btn btn-link">
{{ 'AbpFeatureManagement::Cancel' | abpLocalization }}
</button>
<ng-template #abpFooter>
<button abpClose type="button" class="btn btn-link">
{{ 'AbpFeatureManagement::Cancel' | abpLocalization }}
</button>
@if (groups.length) {
<abp-button buttonClass="btn btn-outline-primary" [disabled]="modalBusy" (click)="resetToDefault()"
aria-hidden="true">
{{ 'AbpFeatureManagement::ResetToDefault' | abpLocalization }}
</abp-button>
}
@if (groups().length) {
<abp-button
buttonClass="btn btn-outline-primary"
[disabled]="modalBusy()"
(click)="resetToDefault()"
aria-hidden="true"
>
{{ 'AbpFeatureManagement::ResetToDefault' | abpLocalization }}
</abp-button>
}
@if (groups.length) {
<abp-button iconClass="fa fa-check" [disabled]="modalBusy" (click)="save()" aria-hidden="true">
{{ 'AbpFeatureManagement::Save' | abpLocalization }}
</abp-button>
}
</ng-template>
</abp-modal>
}
@if (groups().length) {
<abp-button
iconClass="fa fa-check"
[disabled]="modalBusy()"
(click)="save()"
aria-hidden="true"
>
{{ 'AbpFeatureManagement::Save' | abpLocalization }}
</abp-button>
}
</ng-template>
</abp-modal>
}

77
npm/ng-packs/packages/feature-management/src/lib/components/feature-management/feature-management.component.ts

@ -1,4 +1,4 @@
import { Component, inject, DOCUMENT, input, output, signal, effect } from '@angular/core';
import {Component, inject, DOCUMENT, input, output, signal, effect, ChangeDetectionStrategy,} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ConfigStateService, LocalizationPipe, TrackByService } from '@abp/ng.core';
@ -29,7 +29,28 @@ enum ValueTypes {
const DEFAULT_PROVIDER_NAME = 'D';
/**
* FeatureDto.ValueType is typed as IStringValueType in Application.Contracts, but the API
* serializes concrete value types at runtime (e.g. SelectionStringValueType includes itemSource).
* generate-proxy only reflects the interface contract, so keep runtime-only shapes here — not in proxy/.
* See: modules/feature-management (FeatureDto, StringValueTypeJsonConverter) and Blazor/MVC cast pattern.
*/
type FeatureWithStyle = FeatureDto & {
style?: Record<string, number>;
initialValue: unknown;
};
type SelectionStringValueType = FeatureDto['valueType'] & {
itemSource?: {
items?: Array<{
value?: string;
displayText?: { resourceName?: string; name?: string };
}>;
};
};
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-feature-management',
templateUrl: './feature-management.component.html',
exportAs: 'abpFeatureManagement',
@ -68,19 +89,19 @@ export class FeatureManagementComponent {
// Internal state
protected readonly _visible = signal(false);
selectedGroupDisplayName: string;
protected readonly selectedGroupDisplayName = signal<string | undefined>(undefined);
groups: Pick<FeatureGroupDto, 'name' | 'displayName'>[] = [];
protected readonly groups = signal<Pick<FeatureGroupDto, 'name' | 'displayName'>[]>([]);
features: {
[group: string]: Array<FeatureDto & { style?: { [key: string]: number }; initialValue: any }>;
};
protected readonly features = signal<{
[group: string]: FeatureWithStyle[];
}>({});
valueTypes = ValueTypes;
defaultProviderName = DEFAULT_PROVIDER_NAME;
modalBusy = false;
protected readonly modalBusy = signal(false);
// Getter/setter for backward compatibility
get visible(): boolean {
@ -124,25 +145,28 @@ export class FeatureManagementComponent {
getFeatures() {
this.service.get(this.providerName()!, this.providerKey()).subscribe(res => {
if (!res.groups?.length) return;
this.groups = res.groups.map(({ name, displayName }) => ({ name, displayName }));
this.selectedGroupDisplayName = this.groups[0].displayName;
this.features = res.groups.reduce(
(acc, val) => ({
...acc,
[val.name]: mapFeatures(val.features, this.document.body?.dir as LocaleDirection),
}),
{},
const groups = res.groups.map(({ name, displayName }) => ({ name, displayName }));
this.groups.set(groups);
this.selectedGroupDisplayName.set(groups[0].displayName);
this.features.set(
res.groups.reduce(
(acc, val) => ({
...acc,
[val.name]: mapFeatures(val.features, this.document.body?.dir as LocaleDirection),
}),
{},
),
);
});
}
save() {
if (this.modalBusy) return;
if (this.modalBusy()) return;
const changedFeatures = [] as UpdateFeatureDto[];
Object.keys(this.features).forEach(key => {
this.features[key].forEach(feature => {
Object.keys(this.features()).forEach(key => {
this.features()[key].forEach(feature => {
if (feature.value !== feature.initialValue)
changedFeatures.push({ name: feature.name, value: `${feature.value}` });
});
@ -153,10 +177,10 @@ export class FeatureManagementComponent {
return;
}
this.modalBusy = true;
this.modalBusy.set(true);
this.service
.update(this.providerName()!, this.providerKey(), { features: changedFeatures })
.pipe(finalize(() => (this.modalBusy = false)))
.pipe(finalize(() => this.modalBusy.set(false)))
.subscribe(() => {
this.visible = false;
@ -194,8 +218,16 @@ export class FeatureManagementComponent {
}
}
getSelectionItems(feature: FeatureWithStyle) {
if (feature.valueType?.name !== ValueTypes.SelectionStringValueType) {
return [];
}
return (feature.valueType as SelectionStringValueType).itemSource?.items ?? [];
}
isParentDisabled(parentName: string, groupName: string, provider: string): boolean {
const children = this.features[groupName]?.filter(f => f.parentName === parentName);
const children = this.features()[groupName]?.filter(f => f.parentName === parentName);
const providerNameValue = this.providerName();
if (children?.length) {
@ -260,7 +292,8 @@ export class FeatureManagementComponent {
}
private getCurrentGroup() {
return this.features[this.selectedGroupDisplayName] ?? [];
const selectedGroup = this.selectedGroupDisplayName();
return selectedGroup ? this.features()[selectedGroup] ?? [] : [];
}
private setFeatureValue(feature: FeatureDto, val: boolean) {

10
npm/ng-packs/packages/feature-management/src/lib/directives/free-text-input.directive.ts

@ -1,13 +1,7 @@
import { Directive, effect, inject, input, ElementRef, Renderer2 } from '@angular/core';
import type { FeatureDto } from '@abp/ng.feature-management/proxy';
// TODO: improve this type
export interface FreeTextType {
valueType: {
validator: {
name: string;
};
};
}
export type FreeTextType = Pick<FeatureDto, 'valueType'>;
export const INPUT_TYPES: Record<string, string> = {
numeric: 'number',

3
npm/ng-packs/packages/generators/package.json

@ -8,6 +8,9 @@
"type": "git",
"url": "https://github.com/abpframework/abp.git"
},
"dependencies": {
"tslib": "^2.3.0"
},
"publishConfig": {
"access": "public"
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save