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/ .nx/
vitest.config.*.timestamp* vitest.config.*.timestamp*
.claude/worktrees

2
npm/ng-packs/.prettierignore

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

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

@ -166,7 +166,7 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n", "executor": "@angular/build:extract-i18n",
"options": { "options": {
"buildTarget": "dev-app:build" "buildTarget": "dev-app:build"
} }
@ -179,7 +179,7 @@
"outputs": ["{workspaceRoot}/coverage/apps/dev-app"], "outputs": ["{workspaceRoot}/coverage/apps/dev-app"],
"options": { "options": {
"passWithNoTests": true, "passWithNoTests": true,
"reportsDirectory": "../../coverage/apps/dev-app", "reportsDirectory": "{projectRoot}/../../coverage/apps/dev-app",
"silent": false "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 { LoaderBarComponent } from '@abp/ng.theme.shared';
import { DynamicLayoutComponent } from '@abp/ng.core'; import { DynamicLayoutComponent } from '@abp/ng.core';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-root', selector: 'app-root',
template: ` template: `
<abp-loader-bar></abp-loader-bar> <abp-loader-bar />
<abp-dynamic-layout></abp-dynamic-layout> <abp-dynamic-layout />
`, `,
imports: [LoaderBarComponent, DynamicLayoutComponent], imports: [LoaderBarComponent, DynamicLayoutComponent],
}) })

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

@ -4,7 +4,7 @@ import {
provideClientHydration, provideClientHydration,
withEventReplay, withEventReplay,
withHttpTransferCacheOptions, withHttpTransferCacheOptions,
withIncrementalHydration, withNoIncrementalHydration,
} from '@angular/platform-browser'; } from '@angular/platform-browser';
import { appRoutes } from './app.routes'; 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 { provideTenantManagementConfig } from '@abp/ng.tenant-management/config';
import { provideFeatureManagementConfig } from '@abp/ng.feature-management'; import { provideFeatureManagementConfig } from '@abp/ng.feature-management';
import { provideThemeBasicConfig } from '@abp/ng.theme.basic'; import { provideThemeBasicConfig } from '@abp/ng.theme.basic';
import { provideAnimations } from '@angular/platform-browser/animations';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@ -46,12 +45,11 @@ export const appConfig: ApplicationConfig = {
provideFeatureManagementConfig(), provideFeatureManagementConfig(),
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideThemeBasicConfig(), provideThemeBasicConfig(),
provideAnimations(),
provideRouter(appRoutes), provideRouter(appRoutes),
provideClientHydration( provideClientHydration(
withEventReplay(), withEventReplay(),
withHttpTransferCacheOptions({}), 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> <small>All 16 field types + Nested Forms (Group & Array) with full accessibility support</small>
</div> </div>
<div class="card-body p-4"> <div class="card-body p-4">
@if (formFields.length) { @if (formFields().length) {
<abp-dynamic-form <abp-dynamic-form
[fields]="formFields" [fields]="formFields()"
[submitButtonText]="'Register'" [submitButtonText]="'Register'"
[showCancelButton]="true" [showCancelButton]="true"
(onSubmit)="submit($event)" (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 { DynamicFormComponent, FormFieldConfig } from '@abp/ng.components/dynamic-form';
import { FormConfigService } from './form-config.service'; import { FormConfigService } from './form-config.service';
@Component({ @Component({
selector: 'app-dynamic-form-page', changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dynamic-form-page.component.html', selector: 'app-dynamic-form-page',
imports: [DynamicFormComponent], templateUrl: './dynamic-form-page.component.html',
imports: [DynamicFormComponent],
}) })
export class DynamicFormPageComponent implements OnInit { export class DynamicFormPageComponent {
readonly dynamicFormComponent = viewChild(DynamicFormComponent); readonly dynamicFormComponent = viewChild(DynamicFormComponent);
protected readonly formConfigService = inject(FormConfigService); protected readonly formConfigService = inject(FormConfigService);
formFields: FormFieldConfig[] = []; readonly formFields = toSignal(this.formConfigService.getFormConfig(), {
initialValue: [] as FormFieldConfig[],
});
ngOnInit() { submit(formData: any) {
this.formConfigService.getFormConfig().subscribe(config => { console.log('✅ Form Submitted Successfully!', formData);
this.formFields = config; console.table(formData);
});
}
submit(formData: any) { alert('✅ Form submitted successfully! Check the console for details.');
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();
}
cancel() { this.dynamicFormComponent()?.resetForm();
console.log('❌ Form Cancelled'); }
alert('Form cancelled');
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> <p class="lead px-lg-5 mx-lg-5">{{ '::LongWelcomeMessage' | abpLocalization }}</p>
@if (!hasLoggedIn) { @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"> iconClass="fa fa-sign-in">
{{ 'AbpAccount::Login' | abpLocalization }} {{ 'AbpAccount::Login' | abpLocalization }}
</abp-button> </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 { 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 { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent, CardBodyComponent, CardComponent } from '@abp/ng.theme.shared'; import { ButtonComponent, CardBodyComponent, CardComponent } from '@abp/ng.theme.shared';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-home', selector: 'app-home',
templateUrl: './home.component.html', templateUrl: './home.component.html',
imports: [ imports: [
@ -18,14 +19,14 @@ import { RouterLink } from '@angular/router';
}) })
export class HomeComponent { export class HomeComponent {
protected readonly authService = inject(AuthService); protected readonly authService = inject(AuthService);
loading = false; readonly loading = signal(false);
get hasLoggedIn(): boolean { get hasLoggedIn(): boolean {
return this.authService.isAuthenticated; return this.authService.isAuthenticated;
} }
login() { login() {
this.loading = true; this.loading.set(true);
this.authService.navigateToLogin(); 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 { LocalizationPipe, UILocalizationService, SessionStateService } from '@abp/ng.core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CardComponent, CardBodyComponent } from '@abp/ng.theme.shared'; import { CardComponent, CardBodyComponent } from '@abp/ng.theme.shared';
import { AsyncPipe } from '@angular/common';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-localization-test', selector: 'app-localization-test',
standalone: true, imports: [CommonModule, LocalizationPipe, CardComponent, CardBodyComponent],
imports: [CommonModule, LocalizationPipe, CardComponent, CardBodyComponent, AsyncPipe],
template: ` template: `
<div class="container mt-5"> <div class="container mt-5">
<h2>Hybrid Localization Test</h2> <h2>Hybrid Localization Test</h2>
@ -22,7 +22,7 @@ import { AsyncPipe } from '@angular/common';
<abp-card cardClass="mt-4"> <abp-card cardClass="mt-4">
<abp-card-body> <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::CustomKey:</strong> {{ 'MyProjectName::CustomKey' | abpLocalization }}</p>
<p><strong>MyProjectName::TestMessage:</strong> {{ 'MyProjectName::TestMessage' | abpLocalization }}</p> <p><strong>MyProjectName::TestMessage:</strong> {{ 'MyProjectName::TestMessage' | abpLocalization }}</p>
</abp-card-body> </abp-card-body>
@ -39,20 +39,19 @@ import { AsyncPipe } from '@angular/common';
<abp-card cardClass="mt-4"> <abp-card cardClass="mt-4">
<abp-card-body> <abp-card-body>
<h5>Loaded UI Localizations</h5> <h5>Loaded UI Localizations</h5>
<pre>{{ loadedLocalizations | json }}</pre> <pre>{{ loadedLocalizations() | json }}</pre>
</abp-card-body> </abp-card-body>
</abp-card> </abp-card>
</div> </div>
`, `,
}) })
export class LocalizationTestComponent implements OnInit { export class LocalizationTestComponent {
private uiLocalizationService = inject(UILocalizationService); private uiLocalizationService = inject(UILocalizationService);
private sessionState = inject(SessionStateService); private sessionState = inject(SessionStateService);
loadedLocalizations: any = {}; readonly loadedLocalizations = signal(this.uiLocalizationService.getLoadedLocalizations());
currentLanguage$ = this.sessionState.getLanguage$();
ngOnInit() { readonly currentLanguage = toSignal(this.sessionState.getLanguage$(), {
this.loadedLocalizations = this.uiLocalizationService.getLoadedLocalizations(); initialValue: this.sessionState.getLanguage(),
} });
} }

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

@ -28,6 +28,6 @@
"angularCompilerOptions": { "angularCompilerOptions": {
"strictInjectionParameters": false, "strictInjectionParameters": false,
"strictInputAccessModifiers": 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 ### Component Best Practices
1. **Single Responsibility**: Each component should have one clear purpose 1. **Single Responsibility**: Each component should have one clear purpose
2. **Dependency Injection**: Use constructor injection for services 2. **Dependency Injection**: Use `inject()` for services
3. **Lifecycle Management**: Implement `OnInit` and `OnDestroy` when needed 3. **Change detection**: Set `changeDetection: ChangeDetectionStrategy.OnPush` explicitly on every component
4. **State Management**: Use reactive forms and observables 4. **Async state**: Use `signal()` / `computed()` / `toSignal()` — avoid mutating plain properties in `.subscribe()` without a CD trigger
5. **Error Handling**: Implement proper error boundaries 5. **List pages**: Use `toSignal(this.list.hookToQuery(...))` and bind `data().items` in templates
6. **Accessibility**: Follow ARIA guidelines 6. **Component IO**: Prefer signal `input()` / `output()` / `model()` over `@Input` / `@Output`
7. **Performance**: Use `OnPush` change detection when possible 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 ## Service Layer

164
npm/ng-packs/migrations.json

@ -1,118 +1,130 @@
{ {
"migrations": [ "migrations": [
{ {
"version": "22.0.0-beta.1", "cli": "nx",
"description": "Updates release version config based on the breaking changes in Nx v22", "version": "22.6.0-beta.10",
"implementation": "./src/migrations/update-22-0-0/release-version-config-changes", "description": "Adds .claude/worktrees to .gitignore",
"implementation": "./dist/src/migrations/update-22-6-0/add-claude-worktrees-to-git-ignore",
"package": "nx", "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", "cli": "nx",
"description": "Consolidates releaseTag* options into nested releaseTag object structure", "version": "22.7.0-beta.0",
"implementation": "./src/migrations/update-22-0-0/consolidate-release-tag-config", "description": "Adds .nx/polygraph to .gitignore",
"implementation": "./dist/src/migrations/update-22-7-0/add-polygraph-to-git-ignore",
"package": "nx", "package": "nx",
"name": "22-0-0-consolidate-release-tag-config" "name": "22-7-0-add-polygraph-to-git-ignore"
}, },
{ {
"cli": "nx", "cli": "nx",
"version": "22.1.0-beta.5", "version": "22.6.0-rc.0",
"description": "Updates the nx wrapper.", "description": "Adds .claude/settings.local.json to .gitignore",
"implementation": "./src/migrations/update-22-1-0/update-nx-wrapper", "implementation": "./dist/src/migrations/update-17-3-0/update-nxw",
"package": "nx", "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", "cli": "nx",
"description": "Migrate the legacy 'development' custom condition to a workspace-unique custom condition name.", "version": "22.7.0-beta.0",
"factory": "./src/migrations/update-21-5-0/migrate-development-custom-condition", "description": "Adds .nx/self-healing to .gitignore",
"package": "@nx/js", "implementation": "./dist/src/migrations/update-22-2-0/add-self-healing-to-gitignore",
"name": "migrate-development-custom-condition" "package": "nx",
"name": "22-7-0-add-self-healing-to-gitignore"
}, },
{ {
"version": "22.0.0-beta.0", "version": "22.3.2-beta.0",
"description": "Remove the deprecated `external` and `externalBuildTargets` options from the `@nx/js:swc` and `@nx/js:tsc` executors.", "requires": {
"factory": "./src/migrations/update-22-0-0/remove-external-options-from-js-executors", "@angular/build": ">=21.0.0"
"package": "@nx/js", },
"name": "remove-external-options-from-js-executors" "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", "version": "22.6.0-beta.11",
"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.", "description": "Prefix reportsDirectory with {projectRoot} to maintain correct resolution after workspace-root-relative behavior change.",
"factory": "./src/migrations/update-22-1-0/remove-redundant-ts-project-references", "implementation": "./src/migrations/update-22-6-0/prefix-reports-directory-with-project-root",
"package": "@nx/js", "package": "@nx/vitest",
"name": "remove-redundant-ts-project-references" "name": "update-22-6-0-prefix-reports-directory"
}, },
{ {
"version": "21.3.0-beta.3", "version": "22.7.0-beta.12",
"description": "Rename the CLI option `testPathPattern` to `testPathPatterns`.", "description": "Add missing inputs to @nx/eslint:lint executor target defaults",
"implementation": "./src/migrations/update-21-3-0/rename-test-path-pattern", "implementation": "./src/migrations/update-21-6-0/update-executor-lint-inputs",
"package": "@nx/jest", "package": "@nx/eslint",
"name": "rename-test-path-pattern" "name": "update-executor-lint-inputs"
}, },
{ {
"version": "22.2.0-beta.2", "version": "22.3.2-beta.0",
"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.", "requires": {
"implementation": "./src/migrations/update-22-2-0/convert-jest-config-to-cjs", "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", "package": "@nx/jest",
"name": "convert-jest-config-to-cjs" "name": "replace-removed-matcher-aliases-v22-3"
}, },
{ {
"cli": "nx", "version": "22.3.0-beta.0",
"version": "21.3.0-beta.4", "requires": {
"requires": { "@angular/core": ">=20.1.0" }, "@angular/core": ">=21.0.0"
"description": "Update the @angular/cli package version to ~20.1.0.", },
"factory": "./src/migrations/update-21-3-0/update-angular-cli", "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", "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", "version": "22.3.0-beta.0",
"description": "Set the 'tsConfig' option to build and test targets to help with Angular migration issues.", "requires": {
"factory": "./src/migrations/update-21-5-0/set-tsconfig-option", "@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", "package": "@nx/angular",
"name": "set-tsconfig-option" "name": "update-module-resolution-22-2-0"
}, },
{ {
"cli": "nx", "version": "22.3.0-beta.0",
"version": "21.5.0-beta.2", "requires": {
"requires": { "@angular/core": ">=20.2.0" }, "@angular/core": ">=21.0.0"
"description": "Update the @angular/cli package version to ~20.2.0.", },
"factory": "./src/migrations/update-21-5-0/update-angular-cli", "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", "package": "@nx/angular",
"name": "update-angular-cli-version-20-2-0" "name": "update-typescript-lib-22-2-0"
}, },
{ {
"version": "21.5.0-beta.2", "version": "22.3.0-beta.0",
"requires": { "@angular/core": ">=20.2.0" }, "requires": {
"description": "Remove any Karma configuration files that only contain the default content. The default configuration is automatically available without a specific project configurationfile.", "@angular/core": ">=21.0.0"
"factory": "./src/migrations/update-21-5-0/remove-default-karma-configuration-files", },
"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", "package": "@nx/angular",
"name": "remove-default-karma-configuration-files" "name": "update-unit-test-runner-option"
}, },
{ {
"cli": "nx", "version": "22.3.0-beta.3",
"version": "21.6.1-beta.2", "requires": {
"requires": { "@angular/core": ">=20.3.0" }, "@angular/core": ">=21.0.0"
"description": "Update the @angular/cli package version to ~20.3.0.", },
"factory": "./src/migrations/update-21-6-1/update-angular-cli", "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", "package": "@nx/angular",
"name": "update-angular-cli-version-20-3-0" "name": "set-isolated-modules-22-3-0"
}, },
{ {
"version": "20.2.0", "version": "22.3.0-beta.3",
"description": "Replaces usages of the deprecated Router.getCurrentNavigation method with the Router.currentNavigation signal", "requires": {
"factory": "./bundles/router-current-navigation.cjs#migrate", "@angular/core": ">=21.0.0"
"optional": true, },
"package": "@angular/core", "description": "Replace 'jest-preset-angular/setup-jest' imports with the new 'setupZoneTestEnv' function.",
"name": "router-current-navigation" "factory": "./src/migrations/update-22-3-0/update-jest-preset-angular-setup",
}, "package": "@nx/angular",
{ "name": "update-jest-preset-angular-setup"
"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"
} }
] ]
} }

10
npm/ng-packs/nx.json

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

55
npm/ng-packs/package.json

@ -32,7 +32,7 @@
"dep-graph": "nx dep-graph", "dep-graph": "nx dep-graph",
"help": "nx help", "help": "nx help",
"build:schematics": "cd scripts && yarn && yarn build:schematics && cd ..", "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", "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": "./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", "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", "@ng-bootstrap/ng-bootstrap": "~20.0.0",
"@ngneat/spectator": "~19.6.2", "@ngneat/spectator": "~19.6.2",
"@ngx-validate/core": "^0.2.0", "@ngx-validate/core": "^0.2.0",
"@nx/angular": "~22.2.0", "@nx/angular": "~22.7.5",
"@nx/cypress": "~22.2.0", "@nx/cypress": "~22.7.5",
"@nx/devkit": "~22.2.0", "@nx/devkit": "~22.7.5",
"@nx/eslint": "~22.2.0", "@nx/eslint": "~22.7.5",
"@nx/eslint-plugin": "~22.2.0", "@nx/eslint-plugin": "~22.7.5",
"@nx/jest": "~22.2.0", "@nx/jest": "~22.7.5",
"@nx/js": "22.2.7", "@nx/js": "~22.7.5",
"@nx/plugin": "~22.2.0", "@nx/plugin": "~22.7.5",
"@nx/vite": "22.2.7", "@nx/vite": "~22.7.5",
"@nx/vitest": "22.2.7", "@nx/vitest": "~22.7.5",
"@nx/web": "~22.2.0", "@nx/web": "~22.7.5",
"@nx/workspace": "~22.2.0", "@nx/workspace": "~22.7.5",
"@popperjs/core": "~2.11.0", "@popperjs/core": "~2.11.0",
"@schematics/angular": "~21.2.0", "@schematics/angular": "~22.0.0",
"@swc-node/register": "1.9.2", "@swc-node/register": "~1.11.1",
"@swc/cli": "0.6.0", "@swc/cli": "~0.7.10",
"@swc/core": "~1.5.7", "@swc/core": "~1.15.8",
"@swc/helpers": "~0.5.11", "@swc/helpers": "~0.5.23",
"@swimlane/ngx-datatable": "~22.0.0", "@swimlane/ngx-datatable": "~22.0.0",
"@types/express": "~5.0.0", "@types/express": "~5.0.0",
"@types/jest": "29.5.14", "@types/jest": "~30.0.0",
"@types/node": "20.19.9", "@types/node": "20.19.9",
"@typescript-eslint/eslint-plugin": "7.16.0", "@typescript-eslint/eslint-plugin": "7.16.0",
"@typescript-eslint/parser": "7.16.0", "@typescript-eslint/parser": "7.16.0",
@ -117,10 +117,10 @@
"eslint-plugin-cypress": "^3.5.0", "eslint-plugin-cypress": "^3.5.0",
"express": "~5.1.0", "express": "~5.1.0",
"got": "^11.0.0", "got": "^11.0.0",
"jest": "^29.0.0", "jest": "~30.4.2",
"jest-canvas-mock": "^2.0.0", "jest-canvas-mock": "^2.0.0",
"jest-environment-jsdom": "^29.0.0", "jest-environment-jsdom": "~30.4.1",
"jest-preset-angular": "14.6.0", "jest-preset-angular": "~16.0.0",
"jsdom": "~22.1.0", "jsdom": "~22.1.0",
"jsonc-eslint-parser": "^2.0.0", "jsonc-eslint-parser": "^2.0.0",
"jsonc-parser": "^2.0.0", "jsonc-parser": "^2.0.0",
@ -128,9 +128,9 @@
"just-compare": "^2.0.0", "just-compare": "^2.0.0",
"lerna": "^4.0.0", "lerna": "^4.0.0",
"lint-staged": "^13.0.0", "lint-staged": "^13.0.0",
"ng-packagr": "~21.2.0", "ng-packagr": "~22.0.0",
"ng-zorro-antd": "~21.1.0", "ng-zorro-antd": "~21.1.0",
"nx": "~22.2.0", "nx": "~22.7.5",
"postcss": "^8.0.0", "postcss": "^8.0.0",
"postcss-import": "14.1.0", "postcss-import": "14.1.0",
"postcss-preset-env": "7.5.0", "postcss-preset-env": "7.5.0",
@ -140,15 +140,16 @@
"@toast-ui/editor": "~3.0.0", "@toast-ui/editor": "~3.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"should-quote": "^1.0.0", "should-quote": "^1.0.0",
"ts-jest": "29.4.6", "ts-jest": "~29.4.6",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"ts-toolbelt": "^9.0.0", "ts-toolbelt": "^9.0.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"tslint": "~6.1.0", "tslint": "~6.1.0",
"typescript": "~5.9.0", "typescript": "~6.0.0",
"vite": "^8.0.0", "vite": "^8.0.0",
"vitest": "^4.0.0", "vitest": "^4.0.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0",
"jest-util": "~30.4.1"
}, },
"lint-staged": { "lint-staged": {
"**/*.{js,jsx,ts,tsx,html,css,scss}": [ "**/*.{js,jsx,ts,tsx,html,css,scss}": [

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

@ -31,7 +31,7 @@
"executor": "@nx/vitest:test", "executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"], "outputs": ["{options.reportsDirectory}"],
"options": { "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", "executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"], "outputs": ["{options.reportsDirectory}"],
"options": { "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"> <form
@if (!hideCurrentPassword) { [formGroup]="form"
(ngSubmit)="onSubmit()"
[mapErrorsFn]="mapErrorsFn"
[validateOnSubmit]="true"
class="abp-md-form"
>
@if (!hideCurrentPassword()) {
<div class="mb-3 form-group"> <div class="mb-3 form-group">
<label for="current-password" class="form-label">{{ <label for="current-password" class="form-label">{{
'AbpIdentity::DisplayName:CurrentPassword' | abpLocalization 'AbpIdentity::DisplayName:CurrentPassword' | abpLocalization
}}</label }}</label>
><span> * </span <span> * </span>
><input <input
type="password" type="password"
id="current-password" id="current-password"
class="form-control" class="form-control"
@ -18,9 +24,9 @@
<div class="mb-3 form-group"> <div class="mb-3 form-group">
<label for="new-password" class="form-label">{{ <label for="new-password" class="form-label">{{
'AbpIdentity::DisplayName:NewPassword' | abpLocalization 'AbpIdentity::DisplayName:NewPassword' | abpLocalization
}}</label }}</label>
><span> * </span <span> * </span>
><input <input
type="password" type="password"
id="new-password" id="new-password"
class="form-control" class="form-control"
@ -31,9 +37,9 @@
<div class="mb-3 form-group"> <div class="mb-3 form-group">
<label for="confirm-new-password" class="form-label">{{ <label for="confirm-new-password" class="form-label">{{
'AbpIdentity::DisplayName:NewPasswordConfirm' | abpLocalization 'AbpIdentity::DisplayName:NewPasswordConfirm' | abpLocalization
}}</label }}</label>
><span> * </span <span> * </span>
><input <input
type="password" type="password"
id="confirm-new-password" id="confirm-new-password"
class="form-control" class="form-control"
@ -45,8 +51,9 @@
iconClass="fa fa-check" iconClass="fa fa-check"
buttonClass="btn btn-primary color-white" buttonClass="btn btn-primary color-white"
buttonType="submit" buttonType="submit"
[loading]="inProgress" [loading]="inProgress()"
[disabled]="form?.invalid" [disabled]="form?.invalid"
>{{ 'AbpIdentity::Save' | abpLocalization }}</abp-button
> >
{{ 'AbpIdentity::Save' | abpLocalization }}
</abp-button>
</form> </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 { ProfileService } from '@abp/ng.account.core/proxy';
import { ButtonComponent, getPasswordValidators, ToasterService } from '@abp/ng.theme.shared'; 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 { import {
ReactiveFormsModule, ReactiveFormsModule,
UntypedFormBuilder, UntypedFormBuilder,
@ -18,6 +25,7 @@ const { required } = Validators;
const PASSWORD_FIELDS = ['newPassword', 'repeatNewPassword']; const PASSWORD_FIELDS = ['newPassword', 'repeatNewPassword'];
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-change-password-form', selector: 'abp-change-password-form',
templateUrl: './change-password.component.html', templateUrl: './change-password.component.html',
exportAs: 'abpChangePasswordForm', exportAs: 'abpChangePasswordForm',
@ -40,9 +48,8 @@ export class ChangePasswordComponent
form!: UntypedFormGroup; form!: UntypedFormGroup;
inProgress?: boolean; readonly inProgress = signal(false);
readonly hideCurrentPassword = signal(false);
hideCurrentPassword?: boolean;
mapErrorsFn: Validation.MapErrorsFn = (errors, groupErrors, control) => { mapErrorsFn: Validation.MapErrorsFn = (errors, groupErrors, control) => {
if (PASSWORD_FIELDS.indexOf(String(control?.name)) < 0) return errors; if (PASSWORD_FIELDS.indexOf(String(control?.name)) < 0) return errors;
@ -51,7 +58,7 @@ export class ChangePasswordComponent
}; };
ngOnInit(): void { ngOnInit(): void {
this.hideCurrentPassword = !this.manageProfileState.getProfile()?.hasPassword; this.hideCurrentPassword.set(!this.manageProfileState.getProfile()?.hasPassword);
const passwordValidations = getPasswordValidators(this.injector); 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() { onSubmit() {
if (this.form.invalid) return; if (this.form.invalid) return;
this.inProgress = true; this.inProgress.set(true);
this.profileService this.profileService
.changePassword({ .changePassword({
...(!this.hideCurrentPassword && { currentPassword: this.form.get('password')?.value }), ...(!this.hideCurrentPassword() && { currentPassword: this.form.get('password')?.value }),
newPassword: this.form.get('newPassword')?.value, newPassword: this.form.get('newPassword')?.value,
}) })
.pipe(finalize(() => (this.inProgress = false))) .pipe(finalize(() => this.inProgress.set(false)))
.subscribe({ .subscribe({
next: () => { next: () => {
this.form.reset(); this.form.reset();
@ -95,8 +102,8 @@ export class ChangePasswordComponent
life: 5000, life: 5000,
}); });
if (this.hideCurrentPassword) { if (this.hideCurrentPassword()) {
this.hideCurrentPassword = false; this.hideCurrentPassword.set(false);
this.form.addControl('password', new UntypedFormControl('', [required])); 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> <h4>{{ 'AbpAccount::ForgotPassword' | abpLocalization }}</h4>
@if (!isEmailSent) { @if (!isEmailSent()) {
<form [formGroup]="form" (ngSubmit)="onSubmit()" [validateOnSubmit]="true"> <form [formGroup]="form" (ngSubmit)="onSubmit()" [validateOnSubmit]="true">
<p>{{ 'AbpAccount::SendPasswordResetLink_Information' | abpLocalization }}</p> <p>{{ 'AbpAccount::SendPasswordResetLink_Information' | abpLocalization }}</p>
<div class="mb-3 form-group"> <div class="mb-3 form-group">
<label for="input-email-address" class="form-label">{{ <label for="input-email-address" class="form-label">{{
'AbpAccount::EmailAddress' | abpLocalization 'AbpAccount::EmailAddress' | abpLocalization
}}</label }}</label>
><span> * </span> <span> * </span>
<input type="email" id="input-email-address" class="form-control" formControlName="email" /> <input type="email" id="input-email-address" class="form-control" formControlName="email" />
</div> </div>
<abp-button <abp-button
class="d-block" class="d-block"
buttonClass="mt-2 mb-3 btn btn-primary btn-block" buttonClass="mt-2 mb-3 btn btn-primary btn-block"
[loading]="inProgress" [loading]="inProgress()"
buttonType="submit" buttonType="submit"
[disabled]="form?.invalid" [disabled]="form?.invalid"
> >
{{ 'AbpAccount::Submit' | abpLocalization }} {{ 'AbpAccount::Submit' | abpLocalization }}
</abp-button> </abp-button>
<a routerLink="/account/login" <a routerLink="/account/login">
><i class="fa fa-long-arrow-left me-1" aria-hidden="true"></i <i class="fa fa-long-arrow-left me-1" aria-hidden="true"></i>
>{{ 'AbpAccount::Login' | abpLocalization }}</a {{ 'AbpAccount::Login' | abpLocalization }}
> </a>
</form> </form>
} @else { } @else {
<p> <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 { AccountService } from '@abp/ng.account.core/proxy';
import { Component, inject } from '@angular/core'; import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { import {
ReactiveFormsModule, ReactiveFormsModule,
UntypedFormBuilder, UntypedFormBuilder,
@ -13,6 +13,7 @@ import { RouterLink } from '@angular/router';
import { NgxValidateCoreModule } from '@ngx-validate/core'; import { NgxValidateCoreModule } from '@ngx-validate/core';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-forgot-password', selector: 'abp-forgot-password',
templateUrl: 'forgot-password.component.html', templateUrl: 'forgot-password.component.html',
imports: [ imports: [
@ -29,9 +30,8 @@ export class ForgotPasswordComponent {
form: UntypedFormGroup; form: UntypedFormGroup;
inProgress?: boolean; readonly inProgress = signal(false);
readonly isEmailSent = signal(false);
isEmailSent = false;
constructor() { constructor() {
this.form = this.fb.group({ this.form = this.fb.group({
@ -42,16 +42,16 @@ export class ForgotPasswordComponent {
onSubmit() { onSubmit() {
if (this.form.invalid) return; if (this.form.invalid) return;
this.inProgress = true; this.inProgress.set(true);
this.accountService this.accountService
.sendPasswordResetCode({ .sendPasswordResetCode({
email: this.form.get('email')?.value, email: this.form.get('email')?.value,
appName: 'Angular', appName: 'Angular',
}) })
.pipe(finalize(() => (this.inProgress = false))) .pipe(finalize(() => this.inProgress.set(false)))
.subscribe(() => { .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> }}</a>
</strong> </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"> <div class="mb-3 form-group">
<label for="login-input-user-name-or-email-address" class="form-label">{{ <label for="login-input-user-name-or-email-address" class="form-label">{{
'AbpAccount::UserNameOrEmailAddress' | abpLocalization 'AbpAccount::UserNameOrEmailAddress' | abpLocalization
@ -56,7 +56,7 @@
</div> </div>
<abp-button <abp-button
[loading]="inProgress" [loading]="inProgress()"
buttonType="submit" buttonType="submit"
name="Action" name="Action"
buttonClass="btn-block btn-lg mt-3 btn btn-primary" 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 { import {
ReactiveFormsModule, ReactiveFormsModule,
UntypedFormBuilder, UntypedFormBuilder,
@ -22,6 +22,7 @@ import { getRedirectUrl } from '../../utils';
const { maxLength, required } = Validators; const { maxLength, required } = Validators;
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-login', selector: 'abp-login',
templateUrl: './login.component.html', templateUrl: './login.component.html',
imports: [ imports: [
@ -42,7 +43,7 @@ export class LoginComponent implements OnInit {
form!: UntypedFormGroup; form!: UntypedFormGroup;
inProgress?: boolean; readonly inProgress = signal(false);
isSelfRegistrationEnabled = true; isSelfRegistrationEnabled = true;
@ -71,7 +72,7 @@ export class LoginComponent implements OnInit {
onSubmit() { onSubmit() {
if (this.form.invalid) return; if (this.form.invalid) return;
this.inProgress = true; this.inProgress.set(true);
const { username, password, rememberMe } = this.form.value; const { username, password, rememberMe } = this.form.value;
@ -88,9 +89,9 @@ export class LoginComponent implements OnInit {
'', '',
{ life: 7000 }, { life: 7000 },
); );
return throwError(err); return throwError(() => err);
}), }),
finalize(() => (this.inProgress = false)), finalize(() => this.inProgress.set(false)),
) )
.subscribe(); .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 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="card-body">
<div class="row"> <div class="row">
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<ul class="nav flex-column nav-pills" id="nav-tab" role="tablist"> <ul class="nav flex-column nav-pills" id="nav-tab" role="tablist">
@if (!hideChangePasswordTab && profile()) { @if (!hideChangePasswordTab() && profile()) {
<li class="nav-item" (click)="selectedTab = 0"> <li class="nav-item" (click)="selectedTab.set(0)">
<a <a
class="nav-link" class="nav-link"
[class.active]="selectedTab === 0" [class.active]="selectedTab() === 0"
role="tab" role="tab"
href="javascript:void(0)" href="javascript:void(0)"
>{{ 'AbpUi::ChangePassword' | abpLocalization }}</a >{{ 'AbpUi::ChangePassword' | abpLocalization }}</a
> >
</li> </li>
} }
<li class="nav-item mb-2" (click)="selectedTab = 1"> <li class="nav-item mb-2" (click)="selectedTab.set(1)">
<a <a
class="nav-link" class="nav-link"
[class.active]="selectedTab === 1" [class.active]="selectedTab() === 1"
role="tab" role="tab"
href="javascript:void(0)" href="javascript:void(0)"
>{{ 'AbpAccount::PersonalSettings' | abpLocalization }}</a >{{ 'AbpAccount::PersonalSettings' | abpLocalization }}</a
@ -28,7 +28,7 @@
</div> </div>
@if (profile()) { @if (profile()) {
<div class="col-12 col-md-9"> <div class="col-12 col-md-9">
@if (selectedTab === 0) { @if (selectedTab() === 0) {
<div class="tab-content fade-in"> <div class="tab-content fade-in">
<div class="tab-pane active" role="tabpanel"> <div class="tab-pane active" role="tabpanel">
<h4> <h4>
@ -39,11 +39,11 @@
*abpReplaceableTemplate="{ *abpReplaceableTemplate="{
componentKey: changePasswordKey, componentKey: changePasswordKey,
}" }"
></abp-change-password-form> />
</div> </div>
</div> </div>
} }
@if (selectedTab === 1) { @if (selectedTab() === 1) {
<div class="tab-content fade-in"> <div class="tab-content fade-in">
<div class="tab-pane active" role="tabpanel"> <div class="tab-pane active" role="tabpanel">
<h4> <h4>
@ -54,7 +54,7 @@
*abpReplaceableTemplate="{ *abpReplaceableTemplate="{
componentKey: personalSettingsKey, componentKey: personalSettingsKey,
}" }"
></abp-personal-settings-form> />
</div> </div>
</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 { ProfileService } from '@abp/ng.account.core/proxy';
import { LoadingDirective } from '@abp/ng.theme.shared'; 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 { toSignal } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs/operators';
import { eAccountComponents } from '../../enums/components'; import { eAccountComponents } from '../../enums/components';
import { ManageProfileStateService } from '../../services/manage-profile.state.service'; import { ManageProfileStateService } from '../../services/manage-profile.state.service';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
@ -11,6 +10,7 @@ import { PersonalSettingsComponent } from '../personal-settings/personal-setting
import { ChangePasswordComponent } from '../change-password/change-password.component'; import { ChangePasswordComponent } from '../change-password/change-password.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-manage-profile', selector: 'abp-manage-profile',
templateUrl: './manage-profile.component.html', templateUrl: './manage-profile.component.html',
styles: [ styles: [
@ -39,36 +39,29 @@ import { ChangePasswordComponent } from '../change-password/change-password.comp
ChangePasswordComponent, ChangePasswordComponent,
LocalizationPipe, LocalizationPipe,
ReplaceableTemplateDirective, ReplaceableTemplateDirective,
LoadingDirective LoadingDirective,
], ],
}) })
export class ManageProfileComponent implements OnInit { export class ManageProfileComponent implements OnInit {
protected profileService = inject(ProfileService); protected profileService = inject(ProfileService);
protected manageProfileState = inject(ManageProfileStateService); protected manageProfileState = inject(ManageProfileStateService);
selectedTab = 0; readonly selectedTab = signal(0);
readonly hideChangePasswordTab = signal(false);
readonly profile = toSignal(this.manageProfileState.getProfile$());
changePasswordKey = eAccountComponents.ChangePassword; changePasswordKey = eAccountComponents.ChangePassword;
personalSettingsKey = eAccountComponents.PersonalSettings; personalSettingsKey = eAccountComponents.PersonalSettings;
profile = toSignal(this.manageProfileState.getProfile$());
profileLoading = signal(false);
hideChangePasswordTab?: boolean;
ngOnInit() { ngOnInit() {
this.profileLoading.set(true); this.profileService.get().subscribe(profile => {
this.profileService this.manageProfileState.setProfile(profile);
.get() if (profile.isExternal) {
.pipe(finalize(() => this.profileLoading.set(false))) this.hideChangePasswordTab.set(true);
.subscribe(profile => { this.selectedTab.set(1);
this.manageProfileState.setProfile(profile); }
if (profile.isExternal) { });
this.hideChangePasswordTab = true;
this.selectedTab = 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 { import {
EXTENSIONS_FORM_PROP, EXTENSIONS_FORM_PROP,
FormProp, FormProp,
@ -8,6 +8,7 @@ import { ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { LocalizationPipe } from '@abp/ng.core'; import { LocalizationPipe } from '@abp/ng.core';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-personal-settings-half-row', selector: 'abp-personal-settings-half-row',
template: ` <div class="w-50 d-inline"> template: ` <div class="w-50 d-inline">
<label [attr.for]="name" class="form-label">{{ displayName | abpLocalization }} </label> <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) { @if (form) {
<form [formGroup]="form" (ngSubmit)="submit()" validateOnSubmit class="abp-md-form"> <form [formGroup]="form" (ngSubmit)="submit()" [validateOnSubmit]="true" class="abp-md-form">
<abp-extensible-form [selectedRecord]="selected"></abp-extensible-form> <abp-extensible-form [selectedRecord]="selected" />
<abp-button <abp-button
buttonType="submit" buttonType="submit"
iconClass="fa fa-check" iconClass="fa fa-check"
buttonClass="btn btn-primary color-white" buttonClass="btn btn-primary color-white"
[loading]="inProgress" [loading]="inProgress()"
>
{{ 'AbpIdentity::Save' | abpLocalization }}</abp-button
> >
{{ 'AbpIdentity::Save' | abpLocalization }}
</abp-button>
</form> </form>
} }

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

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

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

@ -6,12 +6,7 @@
}}</a> }}</a>
</strong> </strong>
@if (isSelfRegistrationEnabled) { @if (isSelfRegistrationEnabled) {
<form <form [formGroup]="form" (ngSubmit)="onSubmit()" [validateOnSubmit]="true" class="mt-4">
[formGroup]="form"
(ngSubmit)="onSubmit()"
validateOnSubmit
class="mt-4"
>
<div class="mb-3 form-group"> <div class="mb-3 form-group">
<label for="input-user-name" class="form-label">{{ <label for="input-user-name" class="form-label">{{
'AbpAccount::UserName' | abpLocalization 'AbpAccount::UserName' | abpLocalization
@ -47,7 +42,7 @@
/> />
</div> </div>
<abp-button <abp-button
[loading]="inProgress" [loading]="inProgress()"
buttonType="submit" buttonType="submit"
name="Action" name="Action"
buttonClass="btn-block btn-lg mt-3 btn btn-primary" 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, LocalizationPipe,
} from '@abp/ng.core'; } from '@abp/ng.core';
import { ButtonComponent, getPasswordValidators, ToasterService } from '@abp/ng.theme.shared'; 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 { import {
ReactiveFormsModule, ReactiveFormsModule,
UntypedFormBuilder, UntypedFormBuilder,
@ -23,6 +30,7 @@ import { RouterLink } from '@angular/router';
const { maxLength, required, email } = Validators; const { maxLength, required, email } = Validators;
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-register', selector: 'abp-register',
templateUrl: './register.component.html', templateUrl: './register.component.html',
imports: [ imports: [
@ -44,7 +52,7 @@ export class RegisterComponent implements OnInit {
form!: UntypedFormGroup; form!: UntypedFormGroup;
inProgress?: boolean; readonly inProgress = signal(false);
isSelfRegistrationEnabled = true; isSelfRegistrationEnabled = true;
@ -84,7 +92,7 @@ export class RegisterComponent implements OnInit {
onSubmit() { onSubmit() {
if (this.form.invalid) return; if (this.form.invalid) return;
this.inProgress = true; this.inProgress.set(true);
const newUser = { const newUser = {
userName: this.form.get('username')?.value, userName: this.form.get('username')?.value,
@ -114,7 +122,7 @@ export class RegisterComponent implements OnInit {
return throwError(err); return throwError(err);
}), }),
finalize(() => (this.inProgress = false)), finalize(() => this.inProgress.set(false)),
) )
.subscribe(); .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> <h4>{{ 'AbpAccount::ResetPassword' | abpLocalization }}</h4>
@if (!isPasswordReset) { @if (!isPasswordReset()) {
<form [formGroup]="form" [mapErrorsFn]="mapErrorsFn" (ngSubmit)="onSubmit()" validateOnSubmit> @if (form) {
<p>{{ 'AbpAccount::ResetPassword_Information' | abpLocalization }}</p> <form
<div class="mb-3 form-group"> [formGroup]="form"
<label for="input-password" class="form-label">{{ [mapErrorsFn]="mapErrorsFn"
'AbpAccount::Password' | abpLocalization (ngSubmit)="onSubmit()"
}}</label [validateOnSubmit]="true"
><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 }} <p>{{ 'AbpAccount::ResetPassword_Information' | abpLocalization }}</p>
</abp-button> <div class="mb-3 form-group">
</form> <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 { } @else {
<p> <p>
{{ 'AbpAccount::YourPasswordIsSuccessfullyReset' | abpLocalization }} {{ '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 { AccountService } from '@abp/ng.account.core/proxy';
import { ButtonComponent, getPasswordValidators } from '@abp/ng.theme.shared'; 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 { import {
ReactiveFormsModule, ReactiveFormsModule,
UntypedFormBuilder, UntypedFormBuilder,
@ -15,6 +23,7 @@ import { LocalizationPipe } from '@abp/ng.core';
const PASSWORD_FIELDS = ['password', 'confirmPassword']; const PASSWORD_FIELDS = ['password', 'confirmPassword'];
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-reset-password', selector: 'abp-reset-password',
templateUrl: './reset-password.component.html', templateUrl: './reset-password.component.html',
imports: [ imports: [
@ -25,18 +34,19 @@ const PASSWORD_FIELDS = ['password', 'confirmPassword'];
ButtonComponent, ButtonComponent,
], ],
}) })
export class ResetPasswordComponent implements OnInit { export class ResetPasswordComponent {
private fb = inject(UntypedFormBuilder); private fb = inject(UntypedFormBuilder);
private accountService = inject(AccountService); private accountService = inject(AccountService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private injector = inject(Injector); 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) => { mapErrorsFn: Validation.MapErrorsFn = (errors, groupErrors, control) => {
if (PASSWORD_FIELDS.indexOf(String(control?.name)) < 0) return errors; 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')); return errors.concat(groupErrors.filter(({ key }) => key === 'passwordMismatch'));
}; };
ngOnInit(): void { constructor() {
this.route.queryParams.subscribe(({ userId, resetToken }) => { effect(() => {
if (!userId || !resetToken) this.router.navigateByUrl('/account/login'); 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( this.form = this.fb.group(
{ {
@ -63,9 +80,9 @@ export class ResetPasswordComponent implements OnInit {
} }
onSubmit() { onSubmit() {
if (this.form.invalid || this.inProgress) return; if (this.form.invalid || this.inProgress()) return;
this.inProgress = true; this.inProgress.set(true);
this.accountService this.accountService
.resetPassword({ .resetPassword({
@ -73,9 +90,9 @@ export class ResetPasswordComponent implements OnInit {
resetToken: this.form.get('resetToken')?.value, resetToken: this.form.get('resetToken')?.value,
password: this.form.get('password')?.value, password: this.form.get('password')?.value,
}) })
.pipe(finalize(() => (this.inProgress = false))) .pipe(finalize(() => this.inProgress.set(false)))
.subscribe(() => { .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"> <abp-page [title]="'CmsKit::BlogPosts' | abpLocalization">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@if (form && (!isEditMode || blogPost)) { @if (form && (!isEditMode() || blogPost())) {
<form [formGroup]="form" (ngSubmit)="saveAsDraft()" validateOnSubmit> <form [formGroup]="form" (ngSubmit)="saveAsDraft()" [validateOnSubmit]="true">
<!-- Cover Image --> <!-- Cover Image -->
<div class="mb-3"> <div class="mb-3">
@if (coverImagePreview) { @if (coverImagePreview()) {
<div class="mb-2"> <div class="mb-2">
<img [src]="coverImagePreview" height="120" alt="Cover Image" /> <img [src]="coverImagePreview()" height="120" alt="Cover Image" />
<br /> <br />
<button type="button" class="btn btn-link p-0" (click)="removeCoverImage()"> <button type="button" class="btn btn-link p-0" (click)="removeCoverImage()">
{{ 'CmsKit::RemoveCoverImage' | abpLocalization }} {{ 'CmsKit::RemoveCoverImage' | abpLocalization }}
@ -28,7 +28,7 @@
</div> </div>
<!-- Basic fields --> <!-- Basic fields -->
<abp-extensible-form [selectedRecord]="blogPost || {}" /> <abp-extensible-form [selectedRecord]="blogPost() || {}" />
<!-- Content editor --> <!-- Content editor -->
<div class="mt-3"> <div class="mt-3">
@ -38,14 +38,15 @@
</form> </form>
<!-- Tags Editor --> <!-- Tags Editor -->
@if (isTagsEnabled) { @if (isTagsEnabled()) {
<hr /> <hr />
<div class="mb-3"> <div class="mb-3">
<label class="form-label">{{ 'CmsKit::Tags' | abpLocalization }}</label> <label class="form-label">{{ 'CmsKit::Tags' | abpLocalization }}</label>
<input <input
type="text" type="text"
class="form-control" class="form-control"
[(ngModel)]="tags" [ngModel]="tags()"
(ngModelChange)="tags.set($event)"
[ngModelOptions]="{ standalone: true }" [ngModelOptions]="{ standalone: true }"
[placeholder]="'CmsKit::TagsHelpText' | abpLocalization" [placeholder]="'CmsKit::TagsHelpText' | abpLocalization"
/> />
@ -63,7 +64,7 @@
<button class="btn btn-secondary" (click)="saveAsDraft()" [disabled]="form?.invalid"> <button class="btn btn-secondary" (click)="saveAsDraft()" [disabled]="form?.invalid">
{{ 'CmsKit::SaveAsDraft' | abpLocalization }} {{ 'CmsKit::SaveAsDraft' | abpLocalization }}
</button> </button>
@if (isEditMode) { @if (isEditMode()) {
<abp-button (click)="publish()" [disabled]="form?.invalid"> <abp-button (click)="publish()" [disabled]="form?.invalid">
{{ 'CmsKit::Publish' | abpLocalization }} {{ 'CmsKit::Publish' | abpLocalization }}
</abp-button> </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 { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router'; 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 { ReactiveFormsModule, FormsModule, FormGroup, FormControl } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgxValidateCoreModule } from '@ngx-validate/core'; import { NgxValidateCoreModule } from '@ngx-validate/core';
@ -29,6 +37,7 @@ import { eCmsKitAdminComponents } from '../../../enums';
import { BlogPostFormService } from '../../../services'; import { BlogPostFormService } from '../../../services';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-post-form', selector: 'abp-blog-post-form',
templateUrl: './blog-post-form.component.html', templateUrl: './blog-post-form.component.html',
providers: [ providers: [
@ -60,33 +69,33 @@ export class BlogPostFormComponent implements OnInit {
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
form: FormGroup; form: FormGroup;
blogPost: BlogPostDto | null = null; readonly blogPost = signal<BlogPostDto | null>(null);
blogPostId: string | null = null; blogPostId: string | null = null;
isEditMode = false; readonly isEditMode = signal(false);
coverImageFile: File | null = null; coverImageFile: File | null = null;
coverImagePreview: string | null = null; readonly coverImagePreview = signal<string | null>(null);
tags: string = ''; readonly tags = signal('');
isTagsEnabled = true; readonly isTagsEnabled = signal(true);
readonly BLOG_POST_ENTITY_TYPE = 'BlogPost'; readonly BLOG_POST_ENTITY_TYPE = 'BlogPost';
ngOnInit() { ngOnInit() {
const id = this.route.snapshot.params['id']; const id = this.route.snapshot.params['id'];
if (id) { if (id) {
this.isEditMode = true; this.isEditMode.set(true);
this.blogPostId = id; this.blogPostId = id;
this.loadBlogPost(id); this.loadBlogPost(id);
} else { } else {
this.isEditMode = false; this.isEditMode.set(false);
this.buildForm(); this.buildForm();
} }
} }
private loadBlogPost(id: string) { private loadBlogPost(id: string) {
this.blogPostService.get(id).subscribe(blogPost => { this.blogPostService.get(id).subscribe(blogPost => {
this.blogPost = blogPost; this.blogPost.set(blogPost);
if (blogPost.coverImageMediaId) { if (blogPost.coverImageMediaId) {
this.coverImagePreview = `/api/cms-kit/media/${blogPost.coverImageMediaId}`; this.coverImagePreview.set(`/api/cms-kit/media/${blogPost.coverImageMediaId}`);
} }
this.buildForm(); this.buildForm();
this.loadTags(id); this.loadTags(id);
@ -94,7 +103,6 @@ export class BlogPostFormComponent implements OnInit {
} }
private loadTags(blogPostId: string) { private loadTags(blogPostId: string) {
// TODO: use the public service to load the tags
this.restService this.restService
.request<void, TagDto[]>({ .request<void, TagDto[]>({
method: 'GET', method: 'GET',
@ -102,28 +110,27 @@ export class BlogPostFormComponent implements OnInit {
}) })
.subscribe(tags => { .subscribe(tags => {
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
this.tags = tags.map(t => t.name || '').join(', '); this.tags.set(tags.map(t => t.name || '').join(', '));
} }
}); });
} }
private buildForm() { 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); const baseForm = generateFormFromProps(data);
this.form = new FormGroup({ this.form = new FormGroup({
...baseForm.controls, ...baseForm.controls,
content: new FormControl(this.blogPost?.content || ''), content: new FormControl(currentBlogPost?.content || ''),
coverImageMediaId: new FormControl(this.blogPost?.coverImageMediaId || null), coverImageMediaId: new FormControl(currentBlogPost?.coverImageMediaId || null),
}); });
prepareSlugFromControl(this.form, 'title', 'slug', this.destroyRef); prepareSlugFromControl(this.form, 'title', 'slug', this.destroyRef);
// Check if tags feature is enabled for the blog const blogId = this.form.get('blogId')?.value || currentBlogPost?.blogId;
const blogId = this.form.get('blogId')?.value || this.blogPost?.blogId;
if (blogId) { if (blogId) {
this.checkTagsFeature(blogId); this.checkTagsFeature(blogId);
} }
// Listen for blog selection changes
this.form this.form
.get('blogId') .get('blogId')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
@ -142,7 +149,7 @@ export class BlogPostFormComponent implements OnInit {
}) })
.subscribe(feature => { .subscribe(feature => {
const { isEnabled } = 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]; this.coverImageFile = input.files[0];
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e: any) => { reader.onload = (e: any) => {
this.coverImagePreview = e.target.result; this.coverImagePreview.set(e.target.result);
}; };
reader.readAsDataURL(this.coverImageFile); reader.readAsDataURL(this.coverImageFile);
} }
@ -160,7 +167,7 @@ export class BlogPostFormComponent implements OnInit {
removeCoverImage() { removeCoverImage() {
this.coverImageFile = null; this.coverImageFile = null;
this.coverImagePreview = null; this.coverImagePreview.set(null);
this.form.patchValue({ coverImageMediaId: null }); this.form.patchValue({ coverImageMediaId: null });
} }
@ -183,11 +190,12 @@ export class BlogPostFormComponent implements OnInit {
} }
private setTags(blogPostId: string) { private setTags(blogPostId: string) {
if (!this.tags || !this.tags.trim()) { const tagsValue = this.tags();
if (!tagsValue || !tagsValue.trim()) {
return of(null); return of(null);
} }
const tagArray = this.tags const tagArray = tagsValue
.split(',') .split(',')
.map(t => t.trim()) .map(t => t.trim())
.filter(t => t.length > 0); .filter(t => t.length > 0);
@ -204,7 +212,6 @@ export class BlogPostFormComponent implements OnInit {
} }
private executeSaveOperation(operation: 'save' | 'draft' | 'publish' | 'sendToReview') { private executeSaveOperation(operation: 'save' | 'draft' | 'publish' | 'sendToReview') {
// First upload cover image if selected
this.uploadCoverImage() this.uploadCoverImage()
.pipe( .pipe(
tap(coverImageMediaId => { tap(coverImageMediaId => {
@ -213,11 +220,12 @@ export class BlogPostFormComponent implements OnInit {
} }
}), }),
switchMap(() => { switchMap(() => {
if (this.isEditMode) { if (this.isEditMode()) {
if (!this.blogPost || !this.blogPostId) { const currentBlogPost = this.blogPost();
if (!currentBlogPost || !this.blogPostId) {
return of(null); return of(null);
} }
return this.blogPostFormService.update(this.blogPostId, this.form, this.blogPost); return this.blogPostFormService.update(this.blogPostId, this.form, currentBlogPost);
} }
switch (operation) { switch (operation) {
@ -236,7 +244,6 @@ export class BlogPostFormComponent implements OnInit {
if (!result || !result.id) { if (!result || !result.id) {
return of(null); return of(null);
} }
// Set tags after blog post is created/updated
return forkJoin([of(result), this.setTags(result.id)]); 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 mb-4">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
@ -35,6 +35,10 @@
</div> </div>
<div class="card"> <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> </div>
</abp-page> </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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core'; import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core';
@ -14,6 +15,7 @@ import {
import { eCmsKitAdminComponents } from '../../../enums'; import { eCmsKitAdminComponents } from '../../../enums';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-post-list', selector: 'abp-blog-post-list',
templateUrl: './blog-post-list.component.html', templateUrl: './blog-post-list.component.html',
providers: [ providers: [
@ -25,21 +27,35 @@ import { eCmsKitAdminComponents } from '../../../enums';
], ],
imports: [ExtensibleTableComponent, PageComponent, LocalizationPipe, FormsModule, CommonModule], imports: [ExtensibleTableComponent, PageComponent, LocalizationPipe, FormsModule, CommonModule],
}) })
export class BlogPostListComponent implements OnInit { export class BlogPostListComponent {
data: PagedResultDto<BlogPostListDto> = { items: [], totalCount: 0 };
public readonly list = inject(ListService<BlogPostGetListInput>); public readonly list = inject(ListService<BlogPostGetListInput>);
private blogPostService = inject(BlogPostAdminService); private blogPostService = inject(BlogPostAdminService);
private confirmationService = inject(ConfirmationService); 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 = ''; filter = '';
statusFilter: BlogPostStatus | null = null; statusFilter: BlogPostStatus | null = null;
BlogPostStatus = BlogPostStatus; BlogPostStatus = BlogPostStatus;
ngOnInit() {
this.hookToQuery();
}
onSearch() { onSearch() {
this.list.filter = this.filter; this.list.filter = this.filter;
this.list.get(); this.list.get();
@ -49,25 +65,6 @@ export class BlogPostListComponent implements OnInit {
this.list.get(); 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) { delete(id: string, title: string) {
this.confirmationService this.confirmationService
.warn('CmsKit::BlogPostDeletionConfirmationMessage', 'AbpUi::AreYouSure', { .warn('CmsKit::BlogPostDeletionConfirmationMessage', 'AbpUi::AreYouSure', {
@ -77,9 +74,7 @@ export class BlogPostListComponent implements OnInit {
}) })
.subscribe((status: Confirmation.Status) => { .subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) { if (status === Confirmation.Status.confirm) {
this.blogPostService.delete(id).subscribe(() => { this.blogPostService.delete(id).subscribe(() => this.list.get());
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>
<ng-template #abpBody> <ng-template #abpBody>
@if (form) { @if (form(); as currentForm) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit> <form [formGroup]="currentForm" (ngSubmit)="save()" [validateOnSubmit]="true">
<div formArrayName="features"> <div formArrayName="features">
@for (featureControl of featuresFormArray.controls; track $index; let i = $index) { @for (featureControl of featuresFormArray.controls; track $index; let i = $index) {
<div class="mb-3" [formGroupName]="i"> <div class="mb-3" [formGroupName]="i">
@ -19,7 +19,7 @@
formControlName="isEnabled" formControlName="isEnabled"
/> />
<label class="form-check-label" for="feature-{{ i }}"> <label class="form-check-label" for="feature-{{ i }}">
{{ 'CmsKit::' + features[i]?.featureName | abpLocalization }} {{ 'CmsKit::' + features()[i]?.featureName | abpLocalization }}
</label> </label>
</div> </div>
} @else { } @else {
@ -33,10 +33,10 @@
class="form-check-input" class="form-check-input"
id="feature-{{ i }}" id="feature-{{ i }}"
formControlName="isEnabled" formControlName="isEnabled"
disabled [disabled]="true"
/> />
<label class="form-check-label" for="feature-{{ i }}"> <label class="form-check-label" for="feature-{{ i }}">
{{ 'CmsKit::' + features[i]?.featureName | abpLocalization }} {{ 'CmsKit::' + features()[i]?.featureName | abpLocalization }}
</label> </label>
</div> </div>
</div> </div>
@ -58,7 +58,7 @@
<button type="button" class="btn btn-outline-primary" abpClose> <button type="button" class="btn btn-outline-primary" abpClose>
{{ 'AbpUi::Cancel' | abpLocalization }} {{ 'AbpUi::Cancel' | abpLocalization }}
</button> </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 }} {{ 'AbpUi::Save' | abpLocalization }}
</abp-button> </abp-button>
</ng-template> </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 { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormArray, FormBuilder, FormGroup } from '@angular/forms'; import { ReactiveFormsModule, FormArray, FormBuilder, FormGroup } from '@angular/forms';
import { NgxValidateCoreModule } from '@ngx-validate/core'; import { NgxValidateCoreModule } from '@ngx-validate/core';
@ -22,6 +30,7 @@ export interface BlogFeaturesModalVisibleChange {
} }
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-features-modal', selector: 'abp-blog-features-modal',
templateUrl: './blog-features-modal.component.html', templateUrl: './blog-features-modal.component.html',
imports: [ imports: [
@ -42,8 +51,8 @@ export class BlogFeaturesModalComponent implements OnInit {
blogId = input<string>(); blogId = input<string>();
visibleChange = output<BlogFeaturesModalVisibleChange>(); visibleChange = output<BlogFeaturesModalVisibleChange>();
form: FormGroup; readonly form = signal<FormGroup | undefined>(undefined);
features: BlogFeatureDto[] = []; readonly features = signal<BlogFeatureDto[]>([]);
private initialFeatureStates: Map<string, boolean> = new Map(); private initialFeatureStates: Map<string, boolean> = new Map();
ngOnInit() { ngOnInit() {
@ -54,19 +63,19 @@ export class BlogFeaturesModalComponent implements OnInit {
private loadFeatures() { private loadFeatures() {
this.blogFeatureService.getList(this.blogId()!).subscribe(features => { this.blogFeatureService.getList(this.blogId()!).subscribe(features => {
this.features = features.sort((a, b) => const sorted = features.sort((a, b) =>
(a.featureName || '').localeCompare(b.featureName || ''), (a.featureName || '').localeCompare(b.featureName || ''),
); );
// Store initial states this.features.set(sorted);
this.initialFeatureStates = new Map( 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() { private buildForm(features: BlogFeatureDto[]) {
const featureControls = this.features.map(feature => const featureControls = features.map(feature =>
this.fb.group({ this.fb.group({
featureName: [feature.featureName], featureName: [feature.featureName],
isEnabled: [feature.isEnabled], isEnabled: [feature.isEnabled],
@ -74,13 +83,15 @@ export class BlogFeaturesModalComponent implements OnInit {
}), }),
); );
this.form = this.fb.group({ this.form.set(
features: this.fb.array(featureControls), this.fb.group({
}); features: this.fb.array(featureControls),
}),
);
} }
get featuresFormArray(): FormArray { get featuresFormArray(): FormArray {
return this.form.get('features') as FormArray; return this.form()!.get('features') as FormArray;
} }
onVisibleChange(visible: boolean, refresh = false) { onVisibleChange(visible: boolean, refresh = false) {
@ -88,20 +99,19 @@ export class BlogFeaturesModalComponent implements OnInit {
} }
save() { save() {
if (!this.form.valid || !this.blogId()) { const currentForm = this.form();
if (!currentForm?.valid || !this.blogId()) {
return; 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 const changedFeatures: BlogFeatureInputDto[] = featuresArray.controls
.map(control => { .map(control => {
const featureName = control.get('featureName')?.value; const featureName = control.get('featureName')?.value;
const isEnabled = control.get('isEnabled')?.value; const isEnabled = control.get('isEnabled')?.value;
const initialIsEnabled = this.initialFeatureStates.get(featureName); const initialIsEnabled = this.initialFeatureStates.get(featureName);
// Only include if the value has changed
if (featureName && initialIsEnabled !== isEnabled) { if (featureName && initialIsEnabled !== isEnabled) {
return { return {
featureName, featureName,
@ -112,23 +122,18 @@ export class BlogFeaturesModalComponent implements OnInit {
}) })
.filter((input): input is BlogFeatureInputDto => input !== null); .filter((input): input is BlogFeatureInputDto => input !== null);
// If no features changed, just close the modal
if (changedFeatures.length === 0) { if (changedFeatures.length === 0) {
this.onVisibleChange(false, false); this.onVisibleChange(false, false);
return; return;
} }
// Save only changed features
const saveObservables = changedFeatures.map(input => const saveObservables = changedFeatures.map(input =>
this.blogFeatureService.set(this.blogId()!, input), this.blogFeatureService.set(this.blogId()!, input),
); );
// Use forkJoin to save all changed features at once forkJoin(saveObservables).subscribe(() => {
forkJoin(saveObservables).subscribe({ this.onVisibleChange(false, true);
next: () => { this.toasterService.success('AbpUi::SavedSuccessfully');
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"> <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> </div>
@if (isModalVisible) { @if (isModalVisible()) {
<abp-blog-modal [selected]="selected" (visibleChange)="onVisibleModalChange($event)" /> <abp-blog-modal [selected]="selected()" (visibleChange)="onVisibleModalChange($event)" />
} }
@if (isFeaturesModalVisible) { @if (isFeaturesModalVisible()) {
<abp-blog-features-modal <abp-blog-features-modal
[blogId]="selectedBlogId" [blogId]="selectedBlogId()"
(visibleChange)="onFeaturesModalChange($event)" (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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core'; import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core';
@ -14,6 +15,7 @@ import {
} from '../blog-features-modal/blog-features-modal.component'; } from '../blog-features-modal/blog-features-modal.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-list', selector: 'abp-blog-list',
templateUrl: './blog-list.component.html', templateUrl: './blog-list.component.html',
providers: [ providers: [
@ -33,22 +35,33 @@ import {
BlogFeaturesModalComponent, BlogFeaturesModalComponent,
], ],
}) })
export class BlogListComponent implements OnInit { export class BlogListComponent {
data: PagedResultDto<BlogDto> = { items: [], totalCount: 0 };
public readonly list = inject(ListService<BlogGetListInput>); public readonly list = inject(ListService<BlogGetListInput>);
private blogService = inject(BlogAdminService); private blogService = inject(BlogAdminService);
private confirmationService = inject(ConfirmationService); private confirmationService = inject(ConfirmationService);
filter = ''; readonly data = toSignal(
isModalVisible = false; this.list.hookToQuery(query => {
selected?: BlogDto; let filters: Partial<BlogGetListInput> = {};
isFeaturesModalVisible = false; if (this.list.filter) {
selectedBlogId?: string; filters.filter = this.list.filter;
}
const input: BlogGetListInput = {
...query,
...filters,
};
return this.blogService.getList(input);
}),
{
initialValue: { items: [], totalCount: 0 } as PagedResultDto<BlogDto>,
},
);
ngOnInit() { filter = '';
this.hookToQuery(); readonly isModalVisible = signal(false);
} readonly selected = signal<BlogDto | undefined>(undefined);
readonly isFeaturesModalVisible = signal(false);
readonly selectedBlogId = signal<string | undefined>(undefined);
onSearch() { onSearch() {
this.list.filter = this.filter; this.list.filter = this.filter;
@ -56,14 +69,14 @@ export class BlogListComponent implements OnInit {
} }
add() { add() {
this.selected = {} as BlogDto; this.selected.set({} as BlogDto);
this.isModalVisible = true; this.isModalVisible.set(true);
} }
edit(id: string) { edit(id: string) {
this.blogService.get(id).subscribe(blog => { this.blogService.get(id).subscribe(blog => {
this.selected = blog; this.selected.set(blog);
this.isModalVisible = true; this.isModalVisible.set(true);
}); });
} }
@ -74,34 +87,14 @@ export class BlogListComponent implements OnInit {
}) })
.subscribe((status: Confirmation.Status) => { .subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) { if (status === Confirmation.Status.confirm) {
this.blogService.delete(id).subscribe(() => { this.blogService.delete(id).subscribe(() => this.list.get());
this.list.get();
});
} }
}); });
} }
openFeatures(id: string) { openFeatures(id: string) {
this.selectedBlogId = id; this.selectedBlogId.set(id);
this.isFeaturesModalVisible = true; this.isFeaturesModalVisible.set(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;
});
} }
onVisibleModalChange(visibilityChange: BlogModalVisibleChange) { onVisibleModalChange(visibilityChange: BlogModalVisibleChange) {
@ -111,8 +104,8 @@ export class BlogListComponent implements OnInit {
if (visibilityChange.refresh) { if (visibilityChange.refresh) {
this.list.get(); this.list.get();
} }
this.selected = null; this.selected.set(undefined);
this.isModalVisible = false; this.isModalVisible.set(false);
} }
onFeaturesModalChange(visibilityChange: BlogFeaturesModalVisibleChange) { onFeaturesModalChange(visibilityChange: BlogFeaturesModalVisibleChange) {
@ -122,7 +115,7 @@ export class BlogListComponent implements OnInit {
if (visibilityChange.refresh) { if (visibilityChange.refresh) {
this.list.get(); this.list.get();
} }
this.selectedBlogId = null; this.selectedBlogId.set(undefined);
this.isFeaturesModalVisible = false; 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> <ng-template #abpHeader>
<h3> <h3>
{{ (selected()?.id ? 'AbpUi::Edit' : 'AbpUi::New') | abpLocalization }} {{ (selected()?.id ? 'AbpUi::Edit' : 'AbpUi::New') | abpLocalization }}
@ -7,7 +7,7 @@
<ng-template #abpBody> <ng-template #abpBody>
@if (form) { @if (form) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit> <form [formGroup]="form" (ngSubmit)="save()" [validateOnSubmit]="true">
<abp-extensible-form [selectedRecord]="selected()" /> <abp-extensible-form [selectedRecord]="selected()" />
</form> </form>
} @else { } @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 { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormGroup } from '@angular/forms'; import { ReactiveFormsModule, FormGroup } from '@angular/forms';
import { NgxValidateCoreModule } from '@ngx-validate/core'; import { NgxValidateCoreModule } from '@ngx-validate/core';
@ -24,6 +34,7 @@ export interface BlogModalVisibleChange {
} }
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-blog-modal', selector: 'abp-blog-modal',
templateUrl: './blog-modal.component.html', templateUrl: './blog-modal.component.html',
imports: [ imports: [
@ -46,7 +57,7 @@ export class BlogModalComponent implements OnInit {
selected = input<BlogDto>(); selected = input<BlogDto>();
visibleChange = output<BlogModalVisibleChange>(); visibleChange = output<BlogModalVisibleChange>();
modalBusy = false; readonly modalBusy = signal(false);
form: FormGroup; form: FormGroup;
@ -65,12 +76,13 @@ export class BlogModalComponent implements OnInit {
} }
save() { save() {
if (this.modalBusy) { if (this.modalBusy()) {
return; return;
} }
this.modalBusy = true; this.modalBusy.set(true);
if (!this.form.valid) { if (!this.form.valid) {
this.modalBusy.set(false);
return; return;
} }
@ -86,7 +98,7 @@ export class BlogModalComponent implements OnInit {
} as UpdateBlogDto); } as UpdateBlogDto);
} }
observable$.pipe(finalize(() => (this.modalBusy = false))).subscribe(() => { observable$.pipe(finalize(() => this.modalBusy.set(false))).subscribe(() => {
this.onVisibleChange(false, true); this.onVisibleChange(false, true);
this.toasterService.success('AbpUi::SavedSuccessfully'); 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> </div>
<hr class="my-3" /> <hr class="my-3" />
<div> <div>
<abp-button (click)="submit()" iconClass="fa fa-save" [loading]="loading"> <abp-button (click)="submit()" iconClass="fa fa-save" [loading]="loading()">
{{ 'AbpAccount::Save' | abpLocalization }} {{ 'AbpAccount::Save' | abpLocalization }}
</abp-button> </abp-button>
</div> </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 { FormControl, ReactiveFormsModule } from '@angular/forms';
import { import {
NgbNav, NgbNav,
@ -33,11 +33,13 @@ import { CMS_KIT_COMMENTS_REQUIRE_APPROVEMENT } from '../comments';
ReactiveFormsModule, ReactiveFormsModule,
], ],
}) })
export class CmsSettingsComponent { export class CmsSettingsComponent implements OnInit {
readonly commentAdminService = inject(CommentAdminService); readonly commentAdminService = inject(CommentAdminService);
readonly configState = inject(ConfigStateService); readonly configState = inject(ConfigStateService);
readonly toaster = inject(ToasterService); readonly toaster = inject(ToasterService);
readonly loading = signal(false);
commentApprovalControl = new FormControl(false); commentApprovalControl = new FormControl(false);
ngOnInit() { ngOnInit() {
@ -47,10 +49,12 @@ export class CmsSettingsComponent {
} }
submit() { submit() {
this.loading.set(true);
this.commentAdminService this.commentAdminService
.updateSettings({ commentRequireApprovement: this.commentApprovalControl.value }) .updateSettings({ commentRequireApprovement: this.commentApprovalControl.value })
.pipe( .pipe(
finalize(() => { finalize(() => {
this.loading.set(false);
this.configState.refreshAppState().subscribe(); 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"> <abp-page [title]="'CmsKit::Comments' | abpLocalization">
@if (comment) { @if (comment(); as comment) {
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body"> <div class="card-body">
<table class="table"> <table class="table">
@ -59,7 +59,7 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body"> <div class="card-body">
<form [formGroup]="filterForm" (ngSubmit)="onFilter()"> <form [formGroup]="filterForm" (ngSubmit)="onFilter()">
<input type="hidden" [value]="commentId" /> <input type="hidden" [value]="commentId()" />
<div class="row"> <div class="row">
<div class="col-lg-4 col-md-12"> <div class="col-lg-4 col-md-12">
<div class="row"> <div class="row">
@ -142,6 +142,6 @@
<h3>{{ 'CmsKit::RepliesToThisComment' | abpLocalization }}</h3> <h3>{{ 'CmsKit::RepliesToThisComment' | abpLocalization }}</h3>
<div class="card"> <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> </div>
</abp-page> </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 { CommonModule, DatePipe } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@ -18,6 +19,7 @@ import { eCmsKitAdminComponents } from '../../../enums';
import { CommentEntityService } from '../../../services'; import { CommentEntityService } from '../../../services';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-comment-details', selector: 'abp-comment-details',
templateUrl: './comment-details.component.html', templateUrl: './comment-details.component.html',
providers: [ providers: [
@ -46,8 +48,8 @@ import { CommentEntityService } from '../../../services';
], ],
}) })
export class CommentDetailsComponent implements OnInit { export class CommentDetailsComponent implements OnInit {
comment: CommentWithAuthorDto | null = null; readonly comment = signal<CommentWithAuthorDto | null>(null);
data: PagedResultDto<CommentWithAuthorDto> = { items: [], totalCount: 0 }; readonly commentId = signal<string | null>(null);
readonly list = inject(ListService<CommentGetListInput>); readonly list = inject(ListService<CommentGetListInput>);
readonly commentEntityService = inject(CommentEntityService); readonly commentEntityService = inject(CommentEntityService);
@ -57,19 +59,33 @@ export class CommentDetailsComponent implements OnInit {
private router = inject(Router); private router = inject(Router);
private fb = inject(FormBuilder); 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; filterForm!: FormGroup;
commentApproveStateOptions = commentApproveStateOptions; commentApproveStateOptions = commentApproveStateOptions;
commentId!: string;
requireApprovement: boolean; requireApprovement: boolean;
ngOnInit() { ngOnInit() {
this.route.params.subscribe(params => { this.route.params.subscribe(params => {
const id = params['id']; const id = params['id'];
if (id) { if (id) {
this.commentId = id; this.commentId.set(id);
this.loadComment(id); this.loadComment(id);
this.createFilterForm(); this.createFilterForm();
this.hookToQuery(); this.list.get();
} }
}); });
this.requireApprovement = this.commentEntityService.requireApprovement; this.requireApprovement = this.commentEntityService.requireApprovement;
@ -86,7 +102,7 @@ export class CommentDetailsComponent implements OnInit {
private loadComment(id: string) { private loadComment(id: string) {
this.commentService.get(id).subscribe(comment => { 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> = { const filters: Partial<CommentGetListInput> = {
author: formValue.author || undefined, author: formValue.author || undefined,
commentApproveState: formValue.commentApproveState, commentApproveState: formValue.commentApproveState,
repliedCommentId: this.commentId, repliedCommentId: this.commentId() ?? undefined,
creationStartDate: formValue.creationStartDate || undefined, creationStartDate: formValue.creationStartDate || undefined,
creationEndDate: formValue.creationEndDate || undefined, creationEndDate: formValue.creationEndDate || undefined,
}; };
@ -104,20 +120,6 @@ export class CommentDetailsComponent implements OnInit {
this.list.get(); 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) { navigateToReply(id: string) {
this.router.navigate(['/cms/comments', id]); 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>
<div class="card"> <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> </div>
</abp-page> </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 { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NgbDateAdapter, NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbDateAdapter, NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap';
@ -17,6 +18,7 @@ import { eCmsKitAdminComponents } from '../../../enums';
import { CommentEntityService } from '../../../services'; import { CommentEntityService } from '../../../services';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-comment-list', selector: 'abp-comment-list',
templateUrl: './comment-list.component.html', templateUrl: './comment-list.component.html',
providers: [ providers: [
@ -44,21 +46,32 @@ import { CommentEntityService } from '../../../services';
], ],
}) })
export class CommentListComponent implements OnInit { export class CommentListComponent implements OnInit {
data: PagedResultDto<CommentWithAuthorDto> = { items: [], totalCount: 0 };
readonly list = inject(ListService<CommentGetListInput>); readonly list = inject(ListService<CommentGetListInput>);
readonly commentEntityService = inject(CommentEntityService); readonly commentEntityService = inject(CommentEntityService);
private commentService = inject(CommentAdminService); private commentService = inject(CommentAdminService);
private fb = inject(FormBuilder); 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; filterForm!: FormGroup;
commentApproveStateOptions = commentApproveStateOptions; commentApproveStateOptions = commentApproveStateOptions;
requireApprovement: boolean; requireApprovement: boolean;
ngOnInit() { ngOnInit() {
this.createFilterForm(); this.createFilterForm();
this.hookToQuery();
this.requireApprovement = this.commentEntityService.requireApprovement; this.requireApprovement = this.commentEntityService.requireApprovement;
} }
@ -85,17 +98,4 @@ export class CommentListComponent implements OnInit {
this.list.filter = filters as any; this.list.filter = filters as any;
this.list.get(); 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"> <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">
<div class="card-body"> <div class="card-body">
<ul <ul
ngbNav ngbNav
#nav="ngbNav" #nav="ngbNav"
class="nav-tabs" class="nav-tabs"
[(activeId)]="activeTab" [activeId]="activeTab()"
(activeIdChange)="onTabChange($event)" (activeIdChange)="onTabChange($event)"
> >
<li ngbNavItem="script"> <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 { ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -15,6 +22,7 @@ import {
} from '@abp/ng.cms-kit/proxy'; } from '@abp/ng.cms-kit/proxy';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-global-resources', selector: 'abp-global-resources',
imports: [ imports: [
CommonModule, CommonModule,
@ -34,7 +42,7 @@ export class GlobalResourcesComponent implements OnInit {
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
form: FormGroup; form: FormGroup;
activeTab: string = 'script'; readonly activeTab = signal('script');
ngOnInit() { ngOnInit() {
this.buildForm(); this.buildForm();
@ -66,7 +74,7 @@ export class GlobalResourcesComponent implements OnInit {
} }
onTabChange(activeId: string) { onTabChange(activeId: string) {
this.activeTab = activeId; this.activeTab.set(activeId);
} }
save() { 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">
<div class="card-body"> <div class="card-body">
@if (nodes.length > 0) { @if (nodes().length > 0) {
<abp-tree <abp-tree
[nodes]="nodes" [nodes]="nodes()"
[draggable]="draggable" [draggable]="draggable()"
[expandedKeys]="expandedKeys" [expandedKeys]="expandedKeys()"
[selectedNode]="selectedNode" [selectedNode]="selectedNode()"
(selectedNodeChange)="onSelectedNodeChange($event)" (selectedNodeChange)="onSelectedNodeChange($event)"
[beforeDrop]="beforeDrop" [beforeDrop]="beforeDrop"
(dropOver)="onDrop($event)" (dropOver)="onDrop($event)"
@ -47,10 +47,10 @@
</div> </div>
</abp-page> </abp-page>
@if (isModalVisible) { @if (isModalVisible()) {
<abp-menu-item-modal <abp-menu-item-modal
[selected]="selectedMenuItem || undefined" [selected]="selectedMenuItem() || undefined"
[parentId]="parentId" [parentId]="parentId()"
(visibleChange)="onVisibleModalChange($event)" (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 { CommonModule } from '@angular/common';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { PageComponent } from '@abp/ng.components/page'; import { PageComponent } from '@abp/ng.components/page';
@ -19,6 +19,7 @@ import {
} from '../menu-item-modal/menu-item-modal.component'; } from '../menu-item-modal/menu-item-modal.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-menu-item-list', selector: 'abp-menu-item-list',
templateUrl: './menu-item-list.component.html', templateUrl: './menu-item-list.component.html',
imports: [ imports: [
@ -41,13 +42,13 @@ export class MenuItemListComponent implements OnInit {
private menuItemService = inject(MenuItemAdminService); private menuItemService = inject(MenuItemAdminService);
private confirmationService = inject(ConfirmationService); private confirmationService = inject(ConfirmationService);
nodes: any[] = []; readonly nodes = signal<any[]>([]);
selectedNode: MenuItemDto | null = null; readonly selectedNode = signal<MenuItemDto | null>(null);
expandedKeys: string[] = []; readonly expandedKeys = signal<string[]>([]);
draggable = true; readonly draggable = signal(true);
isModalVisible = false; readonly isModalVisible = signal(false);
selectedMenuItem: MenuItemDto | MenuItemWithDetailsDto | null = null; readonly selectedMenuItem = signal<MenuItemDto | MenuItemWithDetailsDto | null>(null);
parentId: string | null = null; readonly parentId = signal<string | null>(null);
ngOnInit() { ngOnInit() {
this.loadMenuItems(); this.loadMenuItems();
@ -56,11 +57,12 @@ export class MenuItemListComponent implements OnInit {
private loadMenuItems() { private loadMenuItems() {
this.menuItemService.getList().subscribe(result => { this.menuItemService.getList().subscribe(result => {
if (result.items && result.items.length > 0) { if (result.items && result.items.length > 0) {
this.nodes = this.buildTreeNodes(result.items); const treeNodes = this.buildTreeNodes(result.items);
// Expand all nodes by default this.nodes.set(treeNodes);
this.expandedKeys = this.nodes.map(n => n.key); this.expandedKeys.set(treeNodes.map(n => n.key));
} else { } 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 nodeMap = new Map<string, any>();
const rootNodes: any[] = []; const rootNodes: any[] = [];
// First pass: create all nodes
items.forEach(item => { items.forEach(item => {
const node: any = { const node: any = {
key: item.id, key: item.id,
@ -81,7 +82,6 @@ export class MenuItemListComponent implements OnInit {
nodeMap.set(item.id!, node); nodeMap.set(item.id!, node);
}); });
// Second pass: build tree structure
items.forEach(item => { items.forEach(item => {
const node = nodeMap.get(item.id!); const node = nodeMap.get(item.id!);
if (item.parentId) { if (item.parentId) {
@ -97,7 +97,6 @@ export class MenuItemListComponent implements OnInit {
} }
}); });
// Sort by order
const sortByOrder = (nodes: any[]) => { const sortByOrder = (nodes: any[]) => {
nodes.sort((a, b) => (a.entity.order || 0) - (b.entity.order || 0)); nodes.sort((a, b) => (a.entity.order || 0) - (b.entity.order || 0));
nodes.forEach(node => { nodes.forEach(node => {
@ -112,7 +111,7 @@ export class MenuItemListComponent implements OnInit {
} }
onSelectedNodeChange(node: any) { onSelectedNodeChange(node: any) {
this.selectedNode = node?.entity || null; this.selectedNode.set(node?.entity || null);
} }
onDrop(event: any) { onDrop(event: any) {
@ -143,16 +142,10 @@ export class MenuItemListComponent implements OnInit {
}; };
this.menuItemService.moveMenuItem(node.id!, input).subscribe({ this.menuItemService.moveMenuItem(node.id!, input).subscribe({
next: () => { next: () => this.loadMenuItems(),
this.loadMenuItems(); error: () => this.loadMenuItems(),
},
error: () => {
// Reload to rollback
this.loadMenuItems();
},
}); });
} else { } else {
// Reload to rollback
this.loadMenuItems(); this.loadMenuItems();
} }
}); });
@ -163,22 +156,22 @@ export class MenuItemListComponent implements OnInit {
}; };
add() { add() {
this.selectedMenuItem = null; this.selectedMenuItem.set(null);
this.parentId = null; this.parentId.set(null);
this.isModalVisible = true; this.isModalVisible.set(true);
} }
addSubMenuItem(parentId?: string) { addSubMenuItem(parentId?: string) {
this.selectedMenuItem = null; this.selectedMenuItem.set(null);
this.parentId = parentId || null; this.parentId.set(parentId || null);
this.isModalVisible = true; this.isModalVisible.set(true);
} }
edit(id: string) { edit(id: string) {
this.menuItemService.get(id).subscribe(menuItem => { this.menuItemService.get(id).subscribe(menuItem => {
this.selectedMenuItem = menuItem; this.selectedMenuItem.set(menuItem);
this.parentId = null; this.parentId.set(null);
this.isModalVisible = true; this.isModalVisible.set(true);
}); });
} }
@ -189,9 +182,9 @@ export class MenuItemListComponent implements OnInit {
if (visibilityChange.refresh) { if (visibilityChange.refresh) {
this.loadMenuItems(); this.loadMenuItems();
} }
this.selectedMenuItem = null; this.selectedMenuItem.set(null);
this.parentId = null; this.parentId.set(null);
this.isModalVisible = false; this.isModalVisible.set(false);
} }
delete(id: string, displayName?: string) { delete(id: string, displayName?: string) {
@ -204,9 +197,7 @@ export class MenuItemListComponent implements OnInit {
.subscribe((status: Confirmation.Status) => { .subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) { if (status === Confirmation.Status.confirm) {
this.menuItemService.delete(id).subscribe({ this.menuItemService.delete(id).subscribe({
next: () => { next: () => this.loadMenuItems(),
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> <ng-template #abpBody>
@if (form) { @if (form) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit> <form [formGroup]="form" (ngSubmit)="save()" [validateOnSubmit]="true">
<ul <ul
ngbNav ngbNav
#nav="ngbNav" #nav="ngbNav"
class="nav-tabs" class="nav-tabs"
[(activeId)]="activeTab" [activeId]="activeTab()"
(activeIdChange)="onTabChange($event)" (activeIdChange)="onTabChange($event)"
> >
<li ngbNavItem="url"> <li ngbNavItem="url">
@ -51,9 +51,9 @@
type="button" type="button"
id="pageSelectDropdown" id="pageSelectDropdown"
ngbDropdownToggle ngbDropdownToggle
[class.text-muted]="!selectedPage" [class.text-muted]="!selectedPage()"
> >
<span>{{ selectedPage?.title || ('CmsKit::Page' | abpLocalization) }}</span> <span>{{ selectedPage()?.title || ('CmsKit::Page' | abpLocalization) }}</span>
</button> </button>
<div <div
class="dropdown-menu w-100" class="dropdown-menu w-100"
@ -65,16 +65,16 @@
<input <input
type="text" type="text"
class="form-control form-control-sm" class="form-control form-control-sm"
[(ngModel)]="pageSearchText" [ngModel]="pageSearchText()"
(ngModelChange)="onPageSearchChange($event)"
[ngModelOptions]="{ standalone: true }" [ngModelOptions]="{ standalone: true }"
(input)="onPageSearchChange($any($event.target).value)"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
placeholder="{{ 'AbpUi::Search' | abpLocalization }}" placeholder="{{ 'AbpUi::Search' | abpLocalization }}"
autocomplete="off" autocomplete="off"
/> />
</div> </div>
@if (filteredPages.length > 0) { @if (filteredPages().length > 0) {
@for (page of filteredPages; track page.id) { @for (page of filteredPages(); track page.id) {
<button <button
type="button" type="button"
class="dropdown-item" class="dropdown-item"
@ -83,16 +83,16 @@
{{ page.title }} {{ page.title }}
</button> </button>
} }
} @else if (pageSearchText && pageSearchText.length > 0) { } @else if (pageSearchText() && pageSearchText().length > 0) {
<div class="dropdown-item text-muted"> <div class="dropdown-item text-muted">
{{ 'No results found' }} {{ 'No results found' }}
</div> </div>
} @else if (pages.length === 0) { } @else if (pages().length === 0) {
<div class="dropdown-item text-muted"> <div class="dropdown-item text-muted">
{{ 'No pages found' }} {{ 'No pages found' }}
</div> </div>
} }
@if (selectedPage) { @if (selectedPage()) {
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<button <button
type="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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormGroup, FormControl, FormsModule } from '@angular/forms'; import { ReactiveFormsModule, FormGroup, FormControl, FormsModule } from '@angular/forms';
@ -43,6 +53,7 @@ const TABS = {
} as const; } as const;
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-menu-item-modal', selector: 'abp-menu-item-modal',
templateUrl: './menu-item-modal.component.html', templateUrl: './menu-item-modal.component.html',
imports: [ imports: [
@ -81,13 +92,13 @@ export class MenuItemModalComponent implements OnInit {
// Form state // Form state
form: FormGroup; form: FormGroup;
activeTab: string = TABS.URL; readonly activeTab = signal<string>(TABS.URL);
// Page selection state // Page search state
pages: PageLookupDto[] = []; readonly pages = signal<PageLookupDto[]>([]);
selectedPage: PageLookupDto | null = null; readonly selectedPage = signal<PageLookupDto | null>(null);
pageSearchText: string = ''; readonly pageSearchText = signal('');
filteredPages: PageLookupDto[] = []; readonly filteredPages = signal<PageLookupDto[]>([]);
// Search subject for debouncing // Search subject for debouncing
private readonly pageSearchSubject = new Subject<string>(); private readonly pageSearchSubject = new Subject<string>();
@ -123,10 +134,10 @@ export class MenuItemModalComponent implements OnInit {
) )
.subscribe({ .subscribe({
next: result => { next: result => {
this.filteredPages = result.items || []; this.filteredPages.set(result.items || []);
}, },
error: () => { error: () => {
this.filteredPages = []; this.filteredPages.set([]);
}, },
}); });
} }
@ -156,8 +167,8 @@ export class MenuItemModalComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: ({ menuItem, pages }) => { next: ({ menuItem, pages }) => {
this.pages = pages.items || []; this.pages.set(pages.items || []);
this.filteredPages = this.pages; this.filteredPages.set(pages.items || []);
this.buildForm(menuItem); this.buildForm(menuItem);
this.initializePageSelection(menuItem); this.initializePageSelection(menuItem);
}, },
@ -176,8 +187,8 @@ export class MenuItemModalComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: result => { next: result => {
this.pages = result.items || []; this.pages.set(result.items || []);
this.filteredPages = this.pages; this.filteredPages.set(result.items || []);
}, },
error: () => { error: () => {
this.toasterService.error('AbpUi::ErrorMessage'); this.toasterService.error('AbpUi::ErrorMessage');
@ -190,16 +201,15 @@ export class MenuItemModalComponent implements OnInit {
*/ */
private initializePageSelection(menuItem: MenuItemWithDetailsDto | MenuItemDto): void { private initializePageSelection(menuItem: MenuItemWithDetailsDto | MenuItemDto): void {
if (menuItem.pageId) { if (menuItem.pageId) {
this.activeTab = TABS.PAGE; this.activeTab.set(TABS.PAGE);
this.selectedPage = this.pages.find(p => p.id === menuItem.pageId) || null; const page = this.pages().find(p => p.id === menuItem.pageId) || null;
this.selectedPage.set(page);
const url = this.selectedPage const url = page ? this.generateUrlFromPage(page) : menuItem.url || '';
? this.generateUrlFromPage(this.selectedPage)
: menuItem.url || '';
this.form.patchValue({ pageId: menuItem.pageId, url }, { emitEvent: false }); this.form.patchValue({ pageId: menuItem.pageId, url }, { emitEvent: false });
this.pageSearchText = this.selectedPage?.title || ''; this.pageSearchText.set(page?.title || '');
} else if (menuItem.url) { } else if (menuItem.url) {
this.activeTab = TABS.URL; this.activeTab.set(TABS.URL);
this.form.patchValue({ url: menuItem.url, pageId: null }, { emitEvent: false }); this.form.patchValue({ url: menuItem.url, pageId: null }, { emitEvent: false });
} }
} }
@ -220,9 +230,9 @@ export class MenuItemModalComponent implements OnInit {
* Handles page search input changes * Handles page search input changes
*/ */
onPageSearchChange(searchText: string): void { onPageSearchChange(searchText: string): void {
this.pageSearchText = searchText; this.pageSearchText.set(searchText);
if (!searchText?.trim()) { if (!searchText?.trim()) {
this.filteredPages = this.pages; this.filteredPages.set(this.pages());
return; return;
} }
this.pageSearchSubject.next(searchText); this.pageSearchSubject.next(searchText);
@ -232,8 +242,8 @@ export class MenuItemModalComponent implements OnInit {
* Handles dropdown open event * Handles dropdown open event
*/ */
onDropdownOpen(): void { onDropdownOpen(): void {
if (!this.pageSearchText?.trim()) { if (!this.pageSearchText()?.trim()) {
this.filteredPages = this.pages; this.filteredPages.set(this.pages());
} }
} }
@ -243,11 +253,11 @@ export class MenuItemModalComponent implements OnInit {
selectPage(page: PageLookupDto): void { selectPage(page: PageLookupDto): void {
if (!page) return; if (!page) return;
this.selectedPage = page; this.selectedPage.set(page);
const url = this.generateUrlFromPage(page); const url = this.generateUrlFromPage(page);
this.form.patchValue({ pageId: page.id, url }, { emitEvent: false }); 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 { clearPageSelection(): void {
this.form.patchValue({ pageId: null }, { emitEvent: false }); this.form.patchValue({ pageId: null }, { emitEvent: false });
this.selectedPage = null; this.selectedPage.set(null);
this.pageSearchText = ''; this.pageSearchText.set('');
this.filteredPages = this.pages; this.filteredPages.set(this.pages());
} }
/** /**
@ -282,7 +292,7 @@ export class MenuItemModalComponent implements OnInit {
* Loads the available menu order for new menu items * Loads the available menu order for new menu items
*/ */
private loadAvailableOrder(parentId: string | null, menuItemId?: string): void { private loadAvailableOrder(parentId: string | null, menuItemId?: string): void {
if (menuItemId) return; // Only needed for new items if (menuItemId) return;
const order$ = parentId const order$ = parentId
? this.menuItemService.getAvailableMenuOrder(parentId) ? this.menuItemService.getAvailableMenuOrder(parentId)
@ -306,7 +316,7 @@ export class MenuItemModalComponent implements OnInit {
* Handles tab changes * Handles tab changes
*/ */
onTabChange(activeId: string): void { 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; const urlValue = this.form.get('url')?.value;
if (urlValue && this.form.get('pageId')?.value) { if (urlValue && this.form.get('pageId')?.value) {
this.clearPageSelection(); this.clearPageSelection();
if (this.activeTab === TABS.PAGE) { if (this.activeTab() === TABS.PAGE) {
this.activeTab = TABS.URL; this.activeTab.set(TABS.URL);
} }
} }
} }
@ -356,17 +366,14 @@ export class MenuItemModalComponent implements OnInit {
const formValue = { ...this.form.value }; const formValue = { ...this.form.value };
if (formValue.pageId) { if (formValue.pageId) {
// If pageId is set, generate URL from the page const page = this.pages().find(p => p.id === formValue.pageId);
const selectedPage = this.pages.find(p => p.id === formValue.pageId); if (page) {
if (selectedPage) { formValue.url = this.generateUrlFromPage(page);
formValue.url = this.generateUrlFromPage(selectedPage);
} }
} else if (formValue.url) { } else if (formValue.url) {
// If URL is manually entered, ensure pageId is cleared
formValue.pageId = null; formValue.pageId = null;
} }
// Clean up undefined values
return { return {
...formValue, ...formValue,
url: formValue.url || undefined, 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"> <abp-page [title]="'CmsKit::Pages' | abpLocalization">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@if (form && (!isEditMode || page)) { @if (form && (!isEditMode() || page())) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit> <form [formGroup]="form" (ngSubmit)="save()" [validateOnSubmit]="true">
<!-- Basic fields outside tabs --> <!-- Basic fields outside tabs -->
<abp-extensible-form [selectedRecord]="page || {}" /> <abp-extensible-form [selectedRecord]="page() || {}" />
<!-- Tabs for Content, Script, and Style --> <!-- Tabs for Content, Script, and Style -->
<ul ngbNav #nav="ngbNav" class="nav-tabs mt-3"> <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 { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms'; import { ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
@ -23,6 +31,7 @@ import { eCmsKitAdminComponents } from '../../../enums';
import { PageFormService } from '../../../services'; import { PageFormService } from '../../../services';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-page-form', selector: 'abp-page-form',
templateUrl: './page-form.component.html', templateUrl: './page-form.component.html',
providers: [ providers: [
@ -52,56 +61,58 @@ export class PageFormComponent implements OnInit {
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
form: FormGroup; form: FormGroup;
page: PageDto | null = null; readonly page = signal<PageDto | null>(null);
pageId: string | null = null; pageId: string | null = null;
isEditMode = false; readonly isEditMode = signal(false);
ngOnInit() { ngOnInit() {
const id = this.route.snapshot.params['id']; const id = this.route.snapshot.params['id'];
if (id) { if (id) {
this.isEditMode = true; this.isEditMode.set(true);
this.pageId = id; this.pageId = id;
this.loadPage(id); this.loadPage(id);
} else { } else {
this.isEditMode = false; this.isEditMode.set(false);
this.buildForm(); this.buildForm();
} }
} }
private loadPage(id: string) { private loadPage(id: string) {
this.pageService.get(id).subscribe(page => { this.pageService.get(id).subscribe(page => {
this.page = page; this.page.set(page);
this.buildForm(); this.buildForm();
}); });
} }
private 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); const baseForm = generateFormFromProps(data);
this.form = new FormGroup({ this.form = new FormGroup({
...baseForm.controls, ...baseForm.controls,
content: new FormControl(this.page?.content || ''), content: new FormControl(currentPage?.content || ''),
script: new FormControl(this.page?.script || ''), script: new FormControl(currentPage?.script || ''),
style: new FormControl(this.page?.style || ''), style: new FormControl(currentPage?.style || ''),
}); });
prepareSlugFromControl(this.form, 'title', 'slug', this.destroyRef); prepareSlugFromControl(this.form, 'title', 'slug', this.destroyRef);
} }
private executeSaveOperation(operation: 'save' | 'draft' | 'publish') { private executeSaveOperation(operation: 'save' | 'draft' | 'publish') {
if (this.isEditMode) { if (this.isEditMode()) {
if (!this.page || !this.pageId) { const currentPage = this.page();
if (!currentPage || !this.pageId) {
return; return;
} }
switch (operation) { switch (operation) {
case 'save': case 'save':
this.pageFormService.update(this.pageId, this.form, this.page).subscribe(); this.pageFormService.update(this.pageId, this.form, currentPage).subscribe();
break; break;
case 'draft': case 'draft':
this.pageFormService.updateAsDraft(this.pageId, this.form, this.page).subscribe(); this.pageFormService.updateAsDraft(this.pageId, this.form, currentPage).subscribe();
break; break;
case 'publish': case 'publish':
this.pageFormService.updateAndPublish(this.pageId, this.form, this.page).subscribe(); this.pageFormService.updateAndPublish(this.pageId, this.form, currentPage).subscribe();
break; break;
} }
return; 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 mb-4">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
@ -21,6 +21,10 @@
</div> </div>
<div class="card"> <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> </div>
</abp-page> </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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core'; 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'; import { eCmsKitAdminComponents } from '../../../enums';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-page-list', selector: 'abp-page-list',
templateUrl: './page-list.component.html', templateUrl: './page-list.component.html',
providers: [ providers: [
@ -20,43 +22,36 @@ import { eCmsKitAdminComponents } from '../../../enums';
], ],
imports: [ExtensibleTableComponent, PageComponent, LocalizationPipe, FormsModule, CommonModule], imports: [ExtensibleTableComponent, PageComponent, LocalizationPipe, FormsModule, CommonModule],
}) })
export class PageListComponent implements OnInit { export class PageListComponent {
data: PagedResultDto<PageDto> = { items: [], totalCount: 0 };
public readonly list = inject(ListService<GetPagesInputDto>); public readonly list = inject(ListService<GetPagesInputDto>);
private pageService = inject(PageAdminService); private pageService = inject(PageAdminService);
private confirmationService = inject(ConfirmationService); private confirmationService = inject(ConfirmationService);
private toasterService = inject(ToasterService); 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() { filter = '';
this.hookToQuery();
}
onSearch() { onSearch() {
this.list.filter = this.filter; this.list.filter = this.filter;
this.list.get(); 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) { delete(id: string) {
this.confirmationService this.confirmationService
.warn('CmsKit::PageDeletionConfirmationMessage', 'AbpUi::AreYouSure', { .warn('CmsKit::PageDeletionConfirmationMessage', 'AbpUi::AreYouSure', {
@ -65,9 +60,7 @@ export class PageListComponent implements OnInit {
}) })
.subscribe((status: Confirmation.Status) => { .subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) { if (status === Confirmation.Status.confirm) {
this.pageService.delete(id).subscribe(() => { this.pageService.delete(id).subscribe(() => this.list.get());
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 mb-4">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
@ -21,10 +21,10 @@
</div> </div>
<div class="card"> <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> </div>
@if (isModalVisible) { @if (isModalVisible()) {
<abp-tag-modal [selected]="selected" (visibleChange)="onVisibleModalChange($event)" /> <abp-tag-modal [selected]="selected()" (visibleChange)="onVisibleModalChange($event)" />
} }
</abp-page> </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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core'; 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'; import { TagModalComponent, TagModalVisibleChange } from '../tag-modal/tag-modal.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-tag-list', selector: 'abp-tag-list',
templateUrl: './tag-list.component.html', templateUrl: './tag-list.component.html',
providers: [ providers: [
@ -28,28 +30,35 @@ import { TagModalComponent, TagModalVisibleChange } from '../tag-modal/tag-modal
TagModalComponent, TagModalComponent,
], ],
}) })
export class TagListComponent implements OnInit { export class TagListComponent {
data: PagedResultDto<TagDto> = { items: [], totalCount: 0 };
public readonly list = inject(ListService<TagGetListInput>); public readonly list = inject(ListService<TagGetListInput>);
private tagService = inject(TagAdminService); private tagService = inject(TagAdminService);
private confirmationService = inject(ConfirmationService); private confirmationService = inject(ConfirmationService);
filter = ''; readonly data = toSignal(
isModalVisible = false; this.list.hookToQuery(query => {
selected?: TagDto; let filters: Partial<TagGetListInput> = {};
tagDefinitions: TagDefinitionDto[] = []; 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() { readonly tagDefinitions = toSignal(this.tagService.getTagDefinitions(), {
this.loadTagDefinitions(); initialValue: [] as TagDefinitionDto[],
this.hookToQuery(); });
}
private loadTagDefinitions() { filter = '';
this.tagService.getTagDefinitions().subscribe(definitions => { readonly isModalVisible = signal(false);
this.tagDefinitions = definitions; readonly selected = signal<TagDto | undefined>(undefined);
});
}
onSearch() { onSearch() {
this.list.filter = this.filter; this.list.filter = this.filter;
@ -57,35 +66,17 @@ export class TagListComponent implements OnInit {
} }
add() { add() {
this.selected = {} as TagDto; this.selected.set({} as TagDto);
this.isModalVisible = true; this.isModalVisible.set(true);
} }
edit(id: string) { edit(id: string) {
this.tagService.get(id).subscribe(tag => { this.tagService.get(id).subscribe(tag => {
this.selected = tag; this.selected.set(tag);
this.isModalVisible = true; 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) { onVisibleModalChange(visibilityChange: TagModalVisibleChange) {
if (visibilityChange.visible) { if (visibilityChange.visible) {
return; return;
@ -93,8 +84,8 @@ export class TagListComponent implements OnInit {
if (visibilityChange.refresh) { if (visibilityChange.refresh) {
this.list.get(); this.list.get();
} }
this.selected = null; this.selected.set(undefined);
this.isModalVisible = false; this.isModalVisible.set(false);
} }
delete(id: string, name: string) { delete(id: string, name: string) {
@ -104,9 +95,7 @@ export class TagListComponent implements OnInit {
}) })
.subscribe((status: Confirmation.Status) => { .subscribe((status: Confirmation.Status) => {
if (status === Confirmation.Status.confirm) { if (status === Confirmation.Status.confirm) {
this.tagService.delete(id).subscribe(() => { this.tagService.delete(id).subscribe(() => this.list.get());
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> <ng-template #abpBody>
@if (form) { @if (form) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit> <form [formGroup]="form" (ngSubmit)="save()" [validateOnSubmit]="true">
<abp-extensible-form [selectedRecord]="selected()" /> <abp-extensible-form [selectedRecord]="selected()" />
</form> </form>
} @else { } @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 { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormGroup } from '@angular/forms'; import { ReactiveFormsModule, FormGroup } from '@angular/forms';
import { NgxValidateCoreModule } from '@ngx-validate/core'; import { NgxValidateCoreModule } from '@ngx-validate/core';
@ -22,6 +30,7 @@ export interface TagModalVisibleChange {
} }
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-tag-modal', selector: 'abp-tag-modal',
templateUrl: './tag-modal.component.html', templateUrl: './tag-modal.component.html',
imports: [ 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', () => { it('should throw when creating with invalid form', () => {
const form = createInvalidForm(); 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 => { 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', () => { it('should throw when updating with invalid form or missing blog post', () => {
const form = createInvalidForm(); 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', 'Form is invalid or blog post is missing',
); );
const validForm = createValidForm(); 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', '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', () => { it('should throw when creating with invalid form', () => {
const form = createInvalidForm(); 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 => { 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', () => { it('should throw when updating with invalid form or missing page', () => {
const form = createInvalidForm(); 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', 'Form is invalid or page is missing',
); );
const validForm = createValidForm(); 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', '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 { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { EditorView, lineNumbers } from '@codemirror/view'; import { EditorView, lineNumbers } from '@codemirror/view';
import { basicSetup } from 'codemirror'; import { basicSetup } from 'codemirror';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-codemirror-editor', selector: 'abp-codemirror-editor',
template: `<div #cmHost class="codemirror-container"></div>`, template: `<div #cmHost class="codemirror-container"></div>`,
providers: [ 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 { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
Component,
AfterViewInit, AfterViewInit,
ViewChild, ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef, ElementRef,
forwardRef, forwardRef,
inject, inject,
PLATFORM_ID, PLATFORM_ID,
DestroyRef, ViewChild,
} from '@angular/core'; } from '@angular/core';
import Editor from '@toast-ui/editor'; 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'; import { THEME_CHANGE_TOKEN } from '@abp/ng.theme.shared';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-toastui-editor', selector: 'abp-toastui-editor',
template: `<div #editorContainer></div>`, template: `<div #editorContainer></div>`,
providers: [ providers: [

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

@ -1,7 +1,6 @@
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
OnDestroy, OnDestroy,
@ -36,7 +35,6 @@ let Chart: any;
}) })
export class ChartComponent implements AfterViewInit, OnDestroy { export class ChartComponent implements AfterViewInit, OnDestroy {
el = inject(ElementRef); el = inject(ElementRef);
private cdr = inject(ChangeDetectorRef);
readonly type = input.required<string>(); readonly type = input.required<string>();
readonly data = input<any>({}); readonly data = input<any>({});
@ -129,7 +127,6 @@ export class ChartComponent implements AfterViewInit, OnDestroy {
refresh = () => { refresh = () => {
if (this.chart) { if (this.chart) {
this.chart.update(); 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 --> <!-- Array Items -->
<div class="array-items" role="list"> <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)"> <div class="array-item border rounded p-3 mb-3" role="listitem" [formGroup]="getItemFormGroup($index)">
<!-- Item Header --> <!-- 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, Component,
input, input,
inject, inject,
ChangeDetectorRef, signal,
forwardRef, forwardRef,
} from '@angular/core'; } from '@angular/core';
import { FormGroup, FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { FormGroup, FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms';
@ -35,7 +35,7 @@ export class DynamicFormArrayComponent {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
private dynamicFormService = inject(DynamicFormService); private dynamicFormService = inject(DynamicFormService);
private cdr = inject(ChangeDetectorRef); readonly formArrayVersion = signal(0);
get formArray(): FormArray { get formArray(): FormArray {
return this.formGroup().get(this.arrayConfig().key) as FormArray; return this.formGroup().get(this.arrayConfig().key) as FormArray;
@ -58,20 +58,20 @@ export class DynamicFormArrayComponent {
addItem() { addItem() {
if (!this.canAddItem) return; if (!this.canAddItem) return;
const itemGroup = this.dynamicFormService.createFormGroup( const itemGroup = this.dynamicFormService.createFormGroup(
this.arrayConfig().children || [] this.arrayConfig().children || []
); );
this.formArray.push(itemGroup); this.formArray.push(itemGroup);
this.cdr.markForCheck(); this.formArrayVersion.update(version => version + 1);
} }
removeItem(index: number) { removeItem(index: number) {
if (!this.canRemoveItem) return; if (!this.canRemoveItem) return;
this.formArray.removeAt(index); this.formArray.removeAt(index);
this.cdr.markForCheck(); this.formArrayVersion.update(version => version + 1);
} }
getItemFormGroup(index: number): FormGroup { 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 { import {Component,
Component,
ViewContainerRef, ViewContainerRef,
ChangeDetectionStrategy, ChangeDetectionStrategy,
forwardRef, forwardRef,
@ -8,8 +7,7 @@ import {
DestroyRef, DestroyRef,
inject, inject,
input, input,
viewChild viewChild,} from '@angular/core';
} from '@angular/core';
import { import {
ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl, ReactiveFormsModule ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl, ReactiveFormsModule
} from '@angular/forms'; } 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()) { @if (visible()) {
<div [formGroup]="fieldFormGroup" role="group" [attr.aria-labelledby]="fieldId + '-label'"> <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 -->
<!-- NOTE: Group and Array types are NOT rendered here, they should be rendered at parent level --> <!-- This component only handles leaf/primitive field types -->
<!-- This component only handles leaf/primitive field types -->
@if (field().type === 'text') {
@if (field().type === 'text') { <!-- Text Input -->
<!-- Text Input --> <div class="form-group mb-3">
<div class="form-group mb-3"> <ng-container [ngTemplateOutlet]="labelTemplate" />
<ng-container [ngTemplateOutlet]="labelTemplate" /> <input
<input [id]="fieldId"
[id]="fieldId" [placeholder]="field().placeholder || '' | abpLocalization"
[placeholder]="(field().placeholder || '') | abpLocalization" formControlName="value"
formControlName="value" [class.is-invalid]="isInvalid()"
[class.is-invalid]="isInvalid" class="form-control"
class="form-control" [attr.aria-required]="field().required || null"
[attr.aria-required]="field().required || null" [attr.aria-invalid]="isInvalid() || null"
[attr.aria-invalid]="isInvalid || null" [attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-describedby]="isInvalid ? errorId : null" [attr.aria-label]="field().label | abpLocalization"
[attr.aria-label]="field().label | abpLocalization"> />
@if (isInvalid) { @if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" /> <ng-container [ngTemplateOutlet]="errorTemplate" />
} }
</div> </div>
} @else if (field().type === 'select') { } @else if (field().type === 'select') {
<!-- Select Dropdown --> <!-- Select Dropdown -->
<div class="form-group mb-3"> <div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" /> <ng-container [ngTemplateOutlet]="labelTemplate" />
<select <select
[id]="fieldId" [id]="fieldId"
formControlName="value" formControlName="value"
[class.is-invalid]="isInvalid" [class.is-invalid]="isInvalid()"
class="form-control" class="form-control"
[attr.aria-required]="field().required || null" [attr.aria-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null" [attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid ? errorId : null" [attr.aria-describedby]="isInvalid() ? errorId : null"
[attr.aria-label]="field().label | abpLocalization"> [attr.aria-label]="field().label | abpLocalization"
<option value="">{{ '::Select' | abpLocalization }}</option> >
@for (option of options$ | async; track option.key) { <option value="">{{ '::Select' | abpLocalization }}</option>
<option [value]="option.key"> @for (option of options$ | async; track option.key) {
{{ option.value | abpLocalization }} <option [value]="option.key">
</option> {{ option.value | abpLocalization }}
} </option>
</select> }
@if (isInvalid) { </select>
<ng-container [ngTemplateOutlet]="errorTemplate" /> @if (isInvalid()) {
} <ng-container [ngTemplateOutlet]="errorTemplate" />
</div> }
} @else if (field().type === 'checkbox') { </div>
<!-- Checkbox --> } @else if (field().type === 'checkbox') {
<div class="form-group form-check mb-3" role="group"> <!-- Checkbox -->
<abp-checkbox <div class="form-group form-check mb-3" role="group">
[label]="field().label | abpLocalization" <abp-checkbox
formControlName="value" [label]="field().label | abpLocalization"
[id]="fieldId" formControlName="value"
[attr.aria-required]="field().required || null" [checkboxId]="fieldId"
[attr.aria-invalid]="isInvalid || null" [id]="fieldId"
[attr.aria-describedby]="isInvalid ? errorId : null" /> [attr.aria-required]="field().required || null"
@if (isInvalid) { [attr.aria-invalid]="isInvalid() || null"
<ng-container [ngTemplateOutlet]="errorTemplate" /> [attr.aria-describedby]="isInvalid() ? errorId : null"
} />
</div> @if (isInvalid()) {
} @else if (field().type === 'email') { <ng-container [ngTemplateOutlet]="errorTemplate" />
<!-- Email Input --> }
<div class="form-group mb-3"> </div>
<ng-container [ngTemplateOutlet]="labelTemplate" /> } @else if (field().type === 'email') {
<input <!-- Email Input -->
type="email" <div class="form-group mb-3">
[id]="fieldId" <ng-container [ngTemplateOutlet]="labelTemplate" />
formControlName="value" <input
[placeholder]="(field().placeholder || '') | abpLocalization" type="email"
[class.is-invalid]="isInvalid" [id]="fieldId"
class="form-control" formControlName="value"
[attr.aria-required]="field().required || null" [placeholder]="field().placeholder || '' | abpLocalization"
[attr.aria-invalid]="isInvalid || null" [class.is-invalid]="isInvalid()"
[attr.aria-describedby]="isInvalid ? errorId : null" class="form-control"
[attr.aria-label]="field().label | abpLocalization" [attr.aria-required]="field().required || null"
autocomplete="email"> [attr.aria-invalid]="isInvalid() || null"
@if (isInvalid) { [attr.aria-describedby]="isInvalid() ? errorId : null"
<ng-container [ngTemplateOutlet]="errorTemplate" /> [attr.aria-label]="field().label | abpLocalization"
} autocomplete="email"
</div> />
} @else if (field().type === 'textarea') { @if (isInvalid()) {
<!-- Textarea --> <ng-container [ngTemplateOutlet]="errorTemplate" />
<div class="form-group mb-3"> }
<ng-container [ngTemplateOutlet]="labelTemplate" /> </div>
<textarea } @else if (field().type === 'textarea') {
[id]="fieldId" <!-- Textarea -->
formControlName="value" <div class="form-group mb-3">
[placeholder]="(field().placeholder || '') | abpLocalization" <ng-container [ngTemplateOutlet]="labelTemplate" />
[class.is-invalid]="isInvalid" <textarea
rows="4" [id]="fieldId"
class="form-control" formControlName="value"
[attr.aria-required]="field().required || null" [placeholder]="field().placeholder || '' | abpLocalization"
[attr.aria-invalid]="isInvalid || null" [class.is-invalid]="isInvalid()"
[attr.aria-describedby]="isInvalid ? errorId : null" rows="4"
[attr.aria-label]="field().label | abpLocalization"> 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> </textarea>
@if (isInvalid) { @if (isInvalid()) {
<ng-container [ngTemplateOutlet]="errorTemplate" /> <ng-container [ngTemplateOutlet]="errorTemplate" />
} }
</div> </div>
} @else if (field().type === 'number') { } @else if (field().type === 'number') {
<!-- Number Input --> <!-- Number Input -->
<div class="form-group mb-3"> <div class="form-group mb-3">
<ng-container [ngTemplateOutlet]="labelTemplate" /> <ng-container [ngTemplateOutlet]="labelTemplate" />
<input <input
type="number" type="number"
[id]="fieldId" [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"
formControlName="value" formControlName="value"
[class.is-invalid]="isInvalid" [placeholder]="field().placeholder || '' | abpLocalization"
class="form-check-input" [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-required]="field().required || null"
[attr.aria-invalid]="isInvalid || null" [attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="isInvalid ? errorId : null"> [attr.aria-describedby]="isInvalid() ? errorId : null"
<label [for]="fieldId + '-' + option.key" class="form-check-label"> [attr.aria-label]="field().label | abpLocalization"
{{ option.value | abpLocalization }} />
</label> @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>
}
</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>
}
</div>
} }
<ng-template #labelTemplate> <ng-template #labelTemplate>
<label <label [for]="fieldId" [id]="fieldId + '-label'" [class.required]="field().required">
[for]="fieldId"
[id]="fieldId + '-label'"
[class.required]="field().required">
{{ field().label | abpLocalization }} {{ field().label | abpLocalization }}
@if (field().required) { @if (field().required) {
<span class="text-danger" aria-label="required">*</span> <span class="text-danger" aria-label="required">*</span>
@ -358,14 +371,9 @@
</ng-template> </ng-template>
<ng-template #errorTemplate> <ng-template #errorTemplate>
<div <div class="invalid-feedback" [id]="errorId" role="alert" aria-live="polite" aria-atomic="true">
class="invalid-feedback" @for (error of errors(); track error) {
[id]="errorId" <div>{{ error | abpLocalization }}</div>
role="alert"
aria-live="polite"
aria-atomic="true">
@for (error of errors; track error) {
<div>{{ error | abpLocalization }}</div>
} }
</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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
forwardRef, forwardRef,
inject, inject,
InjectionToken, Injector, InjectionToken,
Injector,
input, input,
OnInit, OnInit,
signal,
} from '@angular/core'; } from '@angular/core';
import { FormFieldConfig } from '../dynamic-form.models'; import { FormFieldConfig } from '../dynamic-form.models';
import { import {
@ -21,6 +23,7 @@ import {
FormGroup, FormGroup,
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms'; } from '@angular/forms';
import { merge } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgTemplateOutlet, AsyncPipe } from '@angular/common'; import { NgTemplateOutlet, AsyncPipe } from '@angular/common';
import { LocalizationPipe } from '@abp/ng.core'; import { LocalizationPipe } from '@abp/ng.core';
@ -28,7 +31,9 @@ import { FormCheckboxComponent } from '@abp/ng.theme.shared';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { DynamicFormService } from '../dynamic-form.service'; 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 = { const DYNAMIC_FORM_FIELD_CONTROL_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR, provide: NG_VALUE_ACCESSOR,
@ -47,18 +52,24 @@ const DYNAMIC_FORM_FIELD_CONTROL_VALUE_ACCESSOR = {
host: { class: 'abp-dynamic-form-field' }, host: { class: 'abp-dynamic-form-field' },
exportAs: 'abpDynamicFormField', exportAs: 'abpDynamicFormField',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgTemplateOutlet, LocalizationPipe, ReactiveFormsModule, FormCheckboxComponent, AsyncPipe], imports: [
NgTemplateOutlet,
LocalizationPipe,
ReactiveFormsModule,
FormCheckboxComponent,
AsyncPipe,
],
}) })
export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor { export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
field = input.required<FormFieldConfig>(); field = input.required<FormFieldConfig>();
visible = input<boolean>(true); visible = input<boolean>(true);
control!: FormControl; control!: FormControl;
fieldFormGroup: FormGroup; fieldFormGroup: FormGroup;
readonly changeDetectorRef = inject(ChangeDetectorRef);
readonly destroyRef = inject(DestroyRef); readonly destroyRef = inject(DestroyRef);
private injector = inject(Injector); private injector = inject(Injector);
private formBuilder = inject(FormBuilder); private formBuilder = inject(FormBuilder);
private dynamicFormService = inject(DynamicFormService); private dynamicFormService = inject(DynamicFormService);
private readonly controlStateTick = signal(0);
options$: Observable<{ key: string; value: any }[]> = of([]); options$: Observable<{ key: string; value: any }[]> = of([]);
@ -75,6 +86,38 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
return `${this.fieldId}-help`; 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() { constructor() {
this.fieldFormGroup = this.formBuilder.group({ this.fieldFormGroup = this.formBuilder.group({
value: [{ value: '' }], value: [{ value: '' }],
@ -85,6 +128,9 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
const ngControl = this.injector.get(NgControl, null); const ngControl = this.injector.get(NgControl, null);
if (ngControl) { if (ngControl) {
this.control = this.injector.get(FormGroupDirective).getControl(ngControl as FormControlName); 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.value.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.onChange(value); this.onChange(value);
@ -99,9 +145,9 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
options.defaultValues.map(item => { options.defaultValues.map(item => {
return { return {
key: item[options.valueProp || 'key'] || item, key: item[options.valueProp || 'key'] || item,
value: item[options.labelProp || 'value'] || item value: item[options.labelProp || 'value'] || item,
}; };
}) }),
); );
} else { } else {
this.options$ = of([]); this.options$ = of([]);
@ -110,7 +156,6 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
writeValue(value: any[]): void { writeValue(value: any[]): void {
this.value.setValue(value || ''); this.value.setValue(value || '');
this.changeDetectorRef.markForCheck();
} }
registerOnChange(fn: any): void { registerOnChange(fn: any): void {
@ -127,40 +172,8 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
} else { } else {
this.value.enable(); 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() { get value() {
return this.fieldFormGroup.get('value'); return this.fieldFormGroup.get('value');
} }
@ -175,6 +188,6 @@ export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor {
} }
} }
private onChange: (value: any) => void = () => { }; private onChange: (value: any) => void = () => {};
private onTouched: () => void = () => { }; private onTouched: () => void = () => {};
} }

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

@ -6,7 +6,7 @@ import {
inject, inject,
OnInit, OnInit,
DestroyRef, DestroyRef,
ChangeDetectorRef, signal,
} from '@angular/core'; } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -43,10 +43,9 @@ export class DynamicFormComponent implements OnInit {
formCancel = output<void>(); formCancel = output<void>();
private dynamicFormService = inject(DynamicFormService); private dynamicFormService = inject(DynamicFormService);
readonly destroyRef = inject(DestroyRef); readonly destroyRef = inject(DestroyRef);
readonly changeDetectorRef = inject(ChangeDetectorRef);
dynamicForm!: FormGroup; dynamicForm!: FormGroup;
fieldVisibility: { [key: string]: boolean } = {}; readonly fieldVisibility = signal<Record<string, boolean>>({});
ngOnInit() { ngOnInit() {
this.setupFormAndLogic(); this.setupFormAndLogic();
@ -74,7 +73,7 @@ export class DynamicFormComponent implements OnInit {
} }
isFieldVisible(field: FormFieldConfig): boolean { isFieldVisible(field: FormFieldConfig): boolean {
return this.fieldVisibility[field.key] !== false; return this.fieldVisibility()[field.key] !== false;
} }
getChildFormGroup(key: string): FormGroup { getChildFormGroup(key: string): FormGroup {
@ -88,16 +87,14 @@ export class DynamicFormComponent implements OnInit {
this.dynamicForm.reset({ ...initialValues }); this.dynamicForm.reset({ ...initialValues });
this.dynamicForm.markAsUntouched(); this.dynamicForm.markAsUntouched();
this.dynamicForm.markAsPristine(); this.dynamicForm.markAsPristine();
this.changeDetectorRef.markForCheck();
} }
private initializeFieldVisibility() { private initializeFieldVisibility() {
const visibility: Record<string, boolean> = {};
this.fields().forEach(field => { this.fields().forEach(field => {
this.fieldVisibility = { visibility[field.key] = !field.conditionalLogic?.length;
...this.fieldVisibility,
[field.key]: !field.conditionalLogic?.length,
};
}); });
this.fieldVisibility.set(visibility);
} }
private setupConditionalLogic() { private setupConditionalLogic() {
@ -152,10 +149,10 @@ export class DynamicFormComponent implements OnInit {
switch (action) { switch (action) {
case ConditionalAction.SHOW: case ConditionalAction.SHOW:
this.fieldVisibility = { ...this.fieldVisibility, [fieldKey]: shouldApply }; this.fieldVisibility.update(visibility => ({ ...visibility, [fieldKey]: shouldApply }));
break; break;
case ConditionalAction.HIDE: case ConditionalAction.HIDE:
this.fieldVisibility = { ...this.fieldVisibility, [fieldKey]: !shouldApply }; this.fieldVisibility.update(visibility => ({ ...visibility, [fieldKey]: !shouldApply }));
break; break;
case ConditionalAction.ENABLE: case ConditionalAction.ENABLE:
if (control) { if (control) {
@ -174,7 +171,6 @@ export class DynamicFormComponent implements OnInit {
this.dynamicForm = this.dynamicFormService.createFormGroup(this.fields()); this.dynamicForm = this.dynamicFormService.createFormGroup(this.fields());
this.initializeFieldVisibility(); this.initializeFieldVisibility();
this.setupConditionalLogic(); this.setupConditionalLogic();
this.changeDetectorRef.markForCheck();
} }
private markAllFieldsAsTouched() { 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"> <ng-container *abpPermission="prop().permission; runChangeDetection: false">
@switch (getComponent(prop())) { @switch (getComponent(prop())) {
@case ('template') { @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())) { @switch (getComponent(prop())) {
@case ('input') { @case ('input') {
<ng-template [ngTemplateOutlet]="label" /> <ng-template [ngTemplateOutlet]="label" />
@ -15,8 +15,8 @@
[formControlName]="prop().name" [formControlName]="prop().name"
[autocomplete]="prop().autocomplete" [autocomplete]="prop().autocomplete"
[type]="getType(prop())" [type]="getType(prop())"
[abpDisabled]="disabled" [abpDisabled]="disabled()"
[readonly]="readonly" [readonly]="isReadonly()"
class="form-control" class="form-control"
/> />
} }
@ -29,7 +29,7 @@
#field #field
[id]="prop().id" [id]="prop().id"
[formControlName]="prop().name" [formControlName]="prop().name"
[abpDisabled]="disabled" [abpDisabled]="disabled()"
type="checkbox" type="checkbox"
class="form-check-input" class="form-check-input"
/> />
@ -45,10 +45,10 @@
#field #field
[id]="prop().id" [id]="prop().id"
[formControlName]="prop().name" [formControlName]="prop().name"
[abpDisabled]="disabled" [abpDisabled]="disabled()"
class="form-select form-control" 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"> <option [ngValue]="option.value">
@if (prop().isExtra) { @if (prop().isExtra) {
{{ '::' + option.key | abpLocalization }} {{ '::' + option.key | abpLocalization }}
@ -63,9 +63,9 @@
<ng-template [ngTemplateOutlet]="label"></ng-template> <ng-template [ngTemplateOutlet]="label"></ng-template>
<abp-extensible-form-multi-select <abp-extensible-form-multi-select
[prop]="prop()" [prop]="prop()"
[options]="options$ | async" [options]="options$() | async"
[formControlName]="prop().name" [formControlName]="prop().name"
[abpDisabled]="disabled" [abpDisabled]="disabled()"
/> />
} }
@case ('typeahead') { @case ('typeahead') {
@ -75,15 +75,16 @@
#field #field
[id]="prop().id" [id]="prop().id"
[autocomplete]="prop().autocomplete" [autocomplete]="prop().autocomplete"
[abpDisabled]="disabled" [abpDisabled]="disabled()"
[ngbTypeahead]="search" [ngbTypeahead]="search"
[editable]="false" [editable]="false"
[inputFormatter]="typeaheadFormatter" [inputFormatter]="typeaheadFormatter"
[resultFormatter]="typeaheadFormatter" [resultFormatter]="typeaheadFormatter"
[ngModelOptions]="{ standalone: true }" [ngModelOptions]="{ standalone: true }"
[(ngModel)]="typeaheadModel" [ngModel]="typeaheadModel()"
(ngModelChange)="typeaheadModel.set($event)"
(selectItem)="setTypeaheadValue($event.item)" (selectItem)="setTypeaheadValue($event.item)"
(blur)="setTypeaheadValue(typeaheadModel)" (blur)="setTypeaheadValue(typeaheadModel())"
[class.is-invalid]="typeahead.classList.contains('is-invalid')" [class.is-invalid]="typeahead.classList.contains('is-invalid')"
class="form-control" class="form-control"
/> />
@ -117,8 +118,8 @@
#field #field
[id]="prop().id" [id]="prop().id"
[formControlName]="prop().name" [formControlName]="prop().name"
[abpDisabled]="disabled" [abpDisabled]="disabled()"
[readonly]="readonly" [readonly]="isReadonly()"
class="form-control" class="form-control"
></textarea> ></textarea>
} }
@ -129,15 +130,19 @@
class="form-control" class="form-control"
[id]="prop().id" [id]="prop().id"
[formControlName]="prop().name" [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 <i
class="fa" class="fa"
aria-hidden="true" aria-hidden="true"
[class]="{ [class]="{
'fa-eye-slash': !showPassword, 'fa-eye-slash': !showPassword(),
'fa-eye': showPassword, 'fa-eye': showPassword(),
}" }"
></i> ></i>
</button> </button>
@ -163,7 +168,7 @@
{{ prop().displayName | abpLocalization }} {{ prop().displayName | abpLocalization }}
} }
} }
{{ asterisk }} {{ asterisk() }}
@if (prop().tooltip) { @if (prop().tooltip) {
<i <i
[ngbTooltip]="prop().tooltip.text | abpLocalization" [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 { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
computed,
ElementRef, ElementRef,
inject, inject,
Injector, Injector,
@ -19,6 +19,7 @@ import {
viewChild, viewChild,
effect, effect,
input, input,
signal,
} from '@angular/core'; } from '@angular/core';
import { import {
ControlContainer, ControlContainer,
@ -85,7 +86,6 @@ import { ExtensibleFormMultiselectComponent } from '../multi-select/extensible-f
}) })
export class ExtensibleFormPropComponent implements AfterViewInit { export class ExtensibleFormPropComponent implements AfterViewInit {
protected service = inject(ExtensibleFormPropService); protected service = inject(ExtensibleFormPropService);
public readonly cdRef = inject(ChangeDetectorRef);
public readonly track = inject(TrackByService); public readonly track = inject(TrackByService);
#groupDirective = inject(FormGroupDirective); #groupDirective = inject(FormGroupDirective);
private injector = inject(Injector); private injector = inject(Injector);
@ -97,26 +97,24 @@ export class ExtensibleFormPropComponent implements AfterViewInit {
readonly isFirstGroup = input<boolean | undefined>(undefined); readonly isFirstGroup = input<boolean | undefined>(undefined);
private readonly fieldRef = viewChild.required<ElementRef<HTMLElement>>('field'); private readonly fieldRef = viewChild.required<ElementRef<HTMLElement>>('field');
injectorForCustomComponent?: Injector; readonly injectorForCustomComponent = signal<Injector | undefined>(undefined);
asterisk = ''; readonly asterisk = signal('');
containerClassName = 'mb-2'; readonly containerClassName = signal('mb-2');
showPassword = false; readonly showPassword = signal(false);
options$: Observable<ABP.Option<any>[]> = of([]); readonly options$ = signal<Observable<ABP.Option<any>[]>>(of([]));
validators: ValidatorFn[] = []; readonly validators = signal<ValidatorFn[]>([]);
readonly!: boolean; readonly isReadonly = signal(false);
typeaheadModel: any; readonly typeaheadModel = signal<any>(undefined);
passwordKey = eExtensibleComponents.PasswordComponent; passwordKey = eExtensibleComponents.PasswordComponent;
readonly disabledFn = signal<(data: ReadonlyPropData) => boolean>(() => false);
disabledFn = (data: ReadonlyPropData) => false; readonly disabled = computed(() => {
get disabled() {
const data = this.data(); const data = this.data();
if (!data) return false; if (!data) return false;
return this.disabledFn(data); return this.disabledFn()(data);
} });
constructor() { constructor() {
// Watch prop changes and update state
effect(() => { effect(() => {
const currentProp = this.prop(); const currentProp = this.prop();
const data = this.data(); const data = this.data();
@ -125,47 +123,60 @@ export class ExtensibleFormPropComponent implements AfterViewInit {
const { options, readonly, disabled, validators, className, template } = currentProp; const { options, readonly, disabled, validators, className, template } = currentProp;
if (template) { if (template) {
this.injectorForCustomComponent = Injector.create({ this.injectorForCustomComponent.set(
providers: [ Injector.create({
{ providers: [
provide: EXTENSIONS_FORM_PROP, {
useValue: currentProp, provide: EXTENSIONS_FORM_PROP,
}, useValue: currentProp,
{ },
provide: EXTENSIONS_FORM_PROP_DATA, {
useValue: data?.record, provide: EXTENSIONS_FORM_PROP_DATA,
}, useValue: data?.record,
{ provide: ControlContainer, useExisting: FormGroupDirective }, },
], { provide: ControlContainer, useExisting: FormGroupDirective },
parent: this.injector, ],
}); parent: this.injector,
}),
);
} else {
this.injectorForCustomComponent.set(undefined);
} }
if (options) this.options$ = options(data); if (options) this.options$.set(options(data));
if (readonly) this.readonly = readonly(data); if (readonly) this.isReadonly.set(readonly(data));
if (disabled) { if (disabled) {
this.disabledFn = disabled; this.disabledFn.set(disabled);
} else {
this.disabledFn.set(() => false);
} }
if (validators) { if (validators) {
this.validators = validators(data); this.validators.set(validators(data));
this.setAsterisk(); this.asterisk.set(this.service.calcAsterisks(validators(data)));
} else {
this.validators.set([]);
this.asterisk.set('');
} }
if (className !== undefined) { if (className !== undefined) {
this.containerClassName = className; this.containerClassName.set(className);
} else {
this.containerClassName.set('mb-2');
} }
const [keyControl, valueControl] = this.getTypeaheadControls(); const [keyControl, valueControl] = this.getTypeaheadControls();
if (keyControl && valueControl) if (keyControl && valueControl) {
this.typeaheadModel = { key: keyControl.value, value: valueControl.value }; this.typeaheadModel.set({ key: keyControl.value, value: valueControl.value });
}
this.cdRef.markForCheck();
}); });
} }
setTypeaheadValue(selectedOption: ABP.Option<string>) { setTypeaheadValue(selectedOption: ABP.Option<string>) {
this.typeaheadModel = selectedOption || { key: null, value: null }; const model = selectedOption || { key: null, value: null };
const { key, value } = this.typeaheadModel; this.typeaheadModel.set(model);
const { key, value } = model;
const [keyControl, valueControl] = this.getTypeaheadControls(); const [keyControl, valueControl] = this.getTypeaheadControls();
if (valueControl?.value && !value) valueControl.markAsDirty(); if (valueControl?.value && !value) valueControl.markAsDirty();
keyControl?.setValue(key); keyControl?.setValue(key);
@ -200,10 +211,6 @@ export class ExtensibleFormPropComponent implements AfterViewInit {
return [keyControl, valueControl]; return [keyControl, valueControl];
} }
private setAsterisk() {
this.asterisk = this.service.calcAsterisks(this.validators);
}
ngAfterViewInit() { ngAfterViewInit() {
if (this.isFirstGroup() && this.first() && this.fieldRef()) { if (this.isFirstGroup() && this.first() && this.fieldRef()) {
requestAnimationFrame(() => { 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 { TrackByService } from '@abp/ng.core';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
inject, inject,
Optional, Optional,
@ -36,7 +35,6 @@ import { PropDataDirective } from '../../directives/prop-data.directive';
], ],
}) })
export class ExtensibleFormComponent<R = any> { export class ExtensibleFormComponent<R = any> {
public readonly cdRef = inject(ChangeDetectorRef);
public readonly track = inject(TrackByService); public readonly track = inject(TrackByService);
private readonly container = inject(ControlContainer); private readonly container = inject(ControlContainer);
private readonly extensions = inject(ExtensionsService); 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({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-extensible-table-row-detail', selector: 'abp-extensible-table-row-detail',
template: '', 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" [footerHeight]="infiniteScroll() ? false : 50"
> >
@if (effectiveRowDetailTemplate) { @if (effectiveRowDetailTemplate) {
<ngx-datatable-row-detail [rowHeight]="effectiveRowDetailHeight"> @let detailRowHeight = effectiveRowDetailRowHeight();
<ng-template let-row="row" let-expanded="expanded" ngx-datatable-row-detail-template> @if (detailRowHeight !== undefined) {
<ng-container <ngx-datatable-row-detail [rowHeight]="detailRowHeight">
[ngTemplateOutlet]="effectiveRowDetailTemplate" <ng-template let-row="row" let-expanded="expanded" ngx-datatable-row-detail-template>
[ngTemplateOutletContext]="{ row: row, expanded: expanded }" <ng-container
></ng-container> [ngTemplateOutlet]="effectiveRowDetailTemplate"
</ng-template> [ngTemplateOutletContext]="{ row: row, expanded: expanded }"
</ngx-datatable-row-detail> />
</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 <ngx-datatable-column
[width]="50" [width]="50"
@ -54,7 +66,6 @@
> >
<ng-template <ng-template
ngx-datatable-header-template ngx-datatable-header-template
let-value="value"
let-allRowsSelected="allRowsSelected" let-allRowsSelected="allRowsSelected"
let-selectFn="selectFn" let-selectFn="selectFn"
> >
@ -64,7 +75,7 @@
class="form-check-input table-check" class="form-check-input table-check"
type="checkbox" type="checkbox"
[checked]="allRowsSelected" [checked]="allRowsSelected"
(change)="selectFn(!allRowsSelected)" (change)="selectFn()"
/> />
</div> </div>
} }
@ -103,8 +114,8 @@
@if (actionsTemplate() || (actionList.length && hasAtLeastOnePermittedAction)) { @if (actionsTemplate() || (actionList.length && hasAtLeastOnePermittedAction)) {
<ngx-datatable-column <ngx-datatable-column
[name]="actionsText() | abpLocalization" [name]="actionsText() | abpLocalization"
[maxWidth]="_actionsColumnWidth() ?? undefined" [maxWidth]="_actionsColumnWidth()"
[width]="_actionsColumnWidth() ?? 200" [width]="_actionsColumnWidth() || 200"
[canAutoResize]="!_actionsColumnWidth()" [canAutoResize]="!_actionsColumnWidth()"
[sortable]="false" [sortable]="false"
> >
@ -123,7 +134,7 @@
@for (prop of propList; track prop.name; let i = $index) { @for (prop of propList; track prop.name; let i = $index) {
<ngx-datatable-column <ngx-datatable-column
*abpVisible="prop.columnVisible(getInjected)" *abpVisible="prop.columnVisible(getInjected)"
[width]="columnWidths()[i] ?? 200" [width]="columnWidths()[i] || 200"
[canAutoResize]="!columnWidths()[i]" [canAutoResize]="!columnWidths()[i]"
[name]="(prop.isExtra ? '::' + prop.displayName : prop.displayName) | abpLocalization" [name]="(prop.isExtra ? '::' + prop.displayName : prop.displayName) | abpLocalization"
[prop]="prop.name" [prop]="prop.name"
@ -136,17 +147,17 @@
[placement]="prop.tooltip.placement || 'auto'" [placement]="prop.tooltip.placement || 'auto'"
container="body" container="body"
[class.pointer]="prop.sortable" [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> {{ column.name }} <i class="fa fa-info-circle" aria-hidden="true"></i>
</span> </span>
} @else { } @else {
<span [class.pointer]="prop.sortable" (click)="prop.sortable && sortFn(column)"> <span [class.pointer]="prop.sortable" (click)="prop.sortable && sortFn()">
{{ column.name }} {{ column.name }}
</span> </span>
} }
</ng-template> </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 *abpPermission="prop.permission; runChangeDetection: false">
<ng-container *abpVisible="row['_' + prop.name]?.visible"> <ng-container *abpVisible="row['_' + prop.name]?.visible">
@if (!row['_' + prop.name].component) { @if (!row['_' + prop.name].component) {
@ -186,7 +197,7 @@
row['_' + prop.name].component; row['_' + prop.name].component;
injector: row['_' + prop.name].injector injector: row['_' + prop.name].injector
" "
></ng-container> />
} }
</ng-container> </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 { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
computed, computed,
inject, inject,
@ -23,7 +22,12 @@ import { AsyncPipe, isPlatformBrowser, NgComponentOutlet, NgTemplateOutlet } fro
import { Observable, filter, map, Subject, debounceTime, distinctUntilChanged } from 'rxjs'; import { Observable, filter, map, Subject, debounceTime, distinctUntilChanged } from 'rxjs';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; 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 { import {
ABP, ABP,
@ -89,7 +93,6 @@ const DEFAULT_ACTIONS_COLUMN_WIDTH = 150;
export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestroy { export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestroy {
readonly #injector = inject(Injector); readonly #injector = inject(Injector);
readonly getInjected = this.#injector.get.bind(this.#injector); readonly getInjected = this.#injector.get.bind(this.#injector);
protected readonly cdr = inject(ChangeDetectorRef);
protected readonly locale = inject(LOCALE_ID); protected readonly locale = inject(LOCALE_ID);
protected readonly config = inject(ConfigStateService); protected readonly config = inject(ConfigStateService);
protected readonly timeZoneService = inject(TimezoneService); protected readonly timeZoneService = inject(TimezoneService);
@ -130,7 +133,7 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
// Internal signals // Internal signals
protected readonly _data = signal<R[]>([]); 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); readonly rowDetailComponent = contentChild(ExtensibleTableRowDetailComponent);
@ -162,6 +165,27 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
return this.rowDetailComponent()?.rowHeight() ?? this.rowDetailHeight(); 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; hasAtLeastOnePermittedAction: boolean;
readonly propList: EntityPropList<R>; readonly propList: EntityPropList<R>;
@ -299,17 +323,16 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
this.selectionChange.emit(selected); this.selectionChange.emit(selected);
} }
onScroll(scrollEvent: Event): void { onScroll(scrollEvent: ScrollEvent): void {
if (!this.shouldHandleScroll()) { if (!this.shouldHandleScroll()) {
return; return;
} }
const target = scrollEvent.target as HTMLElement; const table = this.table();
if (!target) { const viewportHeight = table.bodyHeight;
return; const scrollHeight = table.bodyComponent.scrollHeight();
}
if (this.isNearScrollBottom(target)) { if (scrollEvent.offsetY + viewportHeight >= scrollHeight - this.scrollThreshold()) {
this.loadMoreSubject.next(); this.loadMoreSubject.next();
} }
} }
@ -318,11 +341,6 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
return this.infiniteScroll() && !this.isLoading(); return this.infiniteScroll() && !this.isLoading();
} }
private isNearScrollBottom(element: HTMLElement): boolean {
const { offsetHeight, scrollTop, scrollHeight } = element;
return offsetHeight + scrollTop >= scrollHeight - this.scrollThreshold();
}
private triggerLoadMore(): void { private triggerLoadMore(): void {
this.loadMore.emit(); this.loadMore.emit();
} }
@ -348,7 +366,6 @@ export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestr
?.requestStatus$?.pipe(filter(status => status === 'loading')) ?.requestStatus$?.pipe(filter(status => status === 'loading'))
.subscribe(() => { .subscribe(() => {
this._data.set([]); this._data.set([]);
this.cdr.markForCheck();
}); });
} }
} }

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

@ -12,7 +12,7 @@
}, },
"dependencies": { "dependencies": {
"chart.js": "^3.5.1", "chart.js": "^3.5.1",
"ng-zorro-antd": "~21.0.0-next.1", "ng-zorro-antd": "~21.3.1",
"@ctrl/tinycolor": "^4.0.0", "@ctrl/tinycolor": "^4.0.0",
"tslib": "^2.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 { export enum PageParts {
title = 'PageTitleContainerComponent', title = 'PageTitleContainerComponent',
@ -7,6 +7,7 @@ export enum PageParts {
} }
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-page-title-container', selector: 'abp-page-title-container',
template: ` <ng-content></ng-content> `, template: ` <ng-content></ng-content> `,
encapsulation: ViewEncapsulation.None, 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 { import {
PageTitleContainerComponent, PageTitleContainerComponent,
PageBreadcrumbContainerComponent, PageBreadcrumbContainerComponent,
@ -10,6 +18,7 @@ import { PageToolbarComponent } from '@abp/ng.components/extensible';
import { PagePartDirective } from './page-part.directive'; import { PagePartDirective } from './page-part.directive';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-page', selector: 'abp-page',
templateUrl: './page.component.html', templateUrl: './page.component.html',
encapsulation: ViewEncapsulation.None, encapsulation: ViewEncapsulation.None,

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

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

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

@ -12,7 +12,7 @@
}, },
"exclude": [ "exclude": [
"src/test-setup.ts", "src/test-setup.ts",
"src/**/*.spec.ts", "**/*.spec.ts",
"jest.config.ts", "jest.config.ts",
"vite.config.ts", "vite.config.ts",
"vite.config.mts", "vite.config.mts",
@ -27,5 +27,5 @@
"src/**/*.spec.jsx", "src/**/*.spec.jsx",
"src/test-setup.ts" "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", "executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"], "outputs": ["{options.reportsDirectory}"],
"options": { "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'; import { ControlValueAccessor } from '@angular/forms';
// Not an abstract class on purpose. Do not change! // Not an abstract class on purpose. Do not change!
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: '', template: '',
}) })
export class AbstractNgModelComponent<T = any, U = T> implements ControlValueAccessor { 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 { import {Component, inject, input, isDevMode, Type, ChangeDetectionStrategy, signal,} from '@angular/core';
Component, import { NgComponentOutlet } from '@angular/common';
inject,
input,
isDevMode,
Type,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { startWith } from 'rxjs/operators';
import { eLayoutType } from '../enums/common'; import { eLayoutType } from '../enums/common';
import { ABP } from '../models'; import { ABP } from '../models';
import { ReplaceableComponents } from '../models/replaceable-components'; import { ReplaceableComponents } from '../models/replaceable-components';
import { LocalizationService } from '../services/localization.service'; import { LocalizationService } from '../services/localization.service';
import { ReplaceableComponentsService } from '../services/replaceable-components.service'; import { ReplaceableComponentsService } from '../services/replaceable-components.service';
import { RouterEvents } from '../services/router-events.service'; import { RouterEvents } from '../services/router-events.service';
import { RoutesService } from '../services/routes.service'; import { RoutesService } from '../services/routes.service';
import { SubscriptionService } from '../services/subscription.service'; import { SubscriptionService } from '../services/subscription.service';
import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service'; import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service';
import { findRoute } from '../utils/route-utils'; import { findRoute } from '../utils/route-utils';
import { TreeNode } from '../utils/tree-utils'; import { TreeNode } from '../utils/tree-utils';
import { DYNAMIC_LAYOUTS_TOKEN } from '../tokens/dynamic-layout.token'; import { DYNAMIC_LAYOUTS_TOKEN } from '../tokens/dynamic-layout.token';
import { EnvironmentService } from '../services';
import { NgComponentOutlet } from '@angular/common';
import { filter, take } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-dynamic-layout', selector: 'abp-dynamic-layout',
template: ` template: `
@if (isLayoutVisible) { @if (isLayoutVisible()) {
<ng-container [ngComponentOutlet]="layout"></ng-container> <ng-container [ngComponentOutlet]="layout()" />
} }
`, `,
providers: [SubscriptionService], providers: [SubscriptionService],
imports: [NgComponentOutlet], imports: [NgComponentOutlet],
}) })
export class DynamicLayoutComponent { export class DynamicLayoutComponent {
layout?: Type<any>; readonly layout = signal<Type<any> | undefined>(undefined);
layoutKey?: eLayoutType; readonly layoutKey = signal<eLayoutType | undefined>(undefined);
readonly layouts = inject(DYNAMIC_LAYOUTS_TOKEN); readonly layouts = inject(DYNAMIC_LAYOUTS_TOKEN);
isLayoutVisible = true; readonly isLayoutVisible = signal(true);
readonly defaultLayout = input<eLayoutType>(undefined); readonly defaultLayout = input<eLayoutType>(undefined);
protected readonly router = inject(Router); protected readonly router = inject(Router);
@ -46,24 +44,25 @@ export class DynamicLayoutComponent {
protected readonly replaceableComponents = inject(ReplaceableComponentsService); protected readonly replaceableComponents = inject(ReplaceableComponentsService);
protected readonly subscription = inject(SubscriptionService); protected readonly subscription = inject(SubscriptionService);
protected readonly routerEvents = inject(RouterEvents); protected readonly routerEvents = inject(RouterEvents);
protected readonly environment = inject(EnvironmentService);
protected readonly routeCultureUrl = inject(RouteBasedCultureUrlService); protected readonly routeCultureUrl = inject(RouteBasedCultureUrlService);
constructor() { constructor() {
const dynamicLayoutComponent = inject(DynamicLayoutComponent, { optional: true, skipSelf: true }); const dynamicLayoutComponent = inject(DynamicLayoutComponent, {
optional: true,
skipSelf: true,
});
if (dynamicLayoutComponent) { if (dynamicLayoutComponent) {
if (isDevMode()) console.warn('DynamicLayoutComponent must be used only in AppComponent.'); if (isDevMode()) console.warn('DynamicLayoutComponent must be used only in AppComponent.');
return; return;
} }
this.checkLayoutOnNavigationEnd(); this.listenToLayoutChanges();
this.listenToLanguageChange(); this.listenToLanguageChange();
this.listenToEnvironmentChange();
} }
private checkLayoutOnNavigationEnd() { private listenToLayoutChanges() {
const navigationEnd$ = this.routerEvents.getNavigationEvents('End'); const navigationEnd$ = this.routerEvents.getNavigationEvents('End');
this.subscription.addOne(navigationEnd$, () => this.getLayout()); this.subscription.addOne(navigationEnd$.pipe(startWith(null)), () => this.getLayout());
} }
private getLayout() { private getLayout() {
@ -71,14 +70,14 @@ export class DynamicLayoutComponent {
if (!expectedLayout) expectedLayout = eLayoutType.empty; if (!expectedLayout) expectedLayout = eLayoutType.empty;
if (this.layoutKey === expectedLayout) return; if (this.layoutKey() === expectedLayout) return;
const key = this.layouts.get(expectedLayout); const key = this.layouts.get(expectedLayout);
if (key) { if (key) {
this.layout = this.getComponent(key)?.component; this.layout.set(this.getComponent(key)?.component);
this.layoutKey = expectedLayout; this.layoutKey.set(expectedLayout);
} }
if (!this.layout) { if (!this.layout()) {
this.showLayoutNotFoundError(expectedLayout); this.showLayoutNotFoundError(expectedLayout);
} }
} }
@ -112,27 +111,12 @@ export class DynamicLayoutComponent {
private listenToLanguageChange() { private listenToLanguageChange() {
this.subscription.addOne(this.localizationService.languageChange$, () => { this.subscription.addOne(this.localizationService.languageChange$, () => {
this.isLayoutVisible = false; this.isLayoutVisible.set(false);
setTimeout(() => (this.isLayoutVisible = true), 0); setTimeout(() => this.isLayoutVisible.set(true), 0);
}); });
} }
private getComponent(key: string): ReplaceableComponents.ReplaceableComponent | undefined { private getComponent(key: string): ReplaceableComponents.ReplaceableComponent | undefined {
return this.replaceableComponents.get(key); 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 { ActivatedRoute } from '@angular/router';
import { distinctUntilChanged } from 'rxjs/operators'; import { distinctUntilChanged } from 'rxjs/operators';
import { ReplaceableComponents } from '../models/replaceable-components'; import { ReplaceableComponents } from '../models/replaceable-components';
@ -7,10 +7,9 @@ import { SubscriptionService } from '../services/subscription.service';
import { NgComponentOutlet } from '@angular/common'; import { NgComponentOutlet } from '@angular/common';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-replaceable-route-container', selector: 'abp-replaceable-route-container',
template: ` template: ` <ng-container *ngComponentOutlet="externalComponent() || defaultComponent" /> `,
<ng-container *ngComponentOutlet="externalComponent || defaultComponent"></ng-container>
`,
providers: [SubscriptionService], providers: [SubscriptionService],
imports: [NgComponentOutlet], imports: [NgComponentOutlet],
}) })
@ -23,7 +22,7 @@ export class ReplaceableRouteContainerComponent implements OnInit {
componentKey!: string; componentKey!: string;
externalComponent?: Type<any>; readonly externalComponent = signal<Type<any> | undefined>(undefined);
ngOnInit() { ngOnInit() {
this.defaultComponent = this.route.snapshot.data.replaceableComponent.defaultComponent; this.defaultComponent = this.route.snapshot.data.replaceableComponent.defaultComponent;
@ -38,7 +37,7 @@ export class ReplaceableRouteContainerComponent implements OnInit {
this.subscription.addOne( this.subscription.addOne(
component$, component$,
(res = {} as ReplaceableComponents.ReplaceableComponent) => { (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'; import { RouterOutlet } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-router-outlet', selector: 'abp-router-outlet',
template: ` <router-outlet></router-outlet> `, template: ` <router-outlet></router-outlet> `,
imports: [RouterOutlet], 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 configStateService = inject(ConfigStateService);
protected readonly routeCultureUrl = inject(RouteBasedCultureUrlService); 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 || {}; let { requiredPolicy } = route.data || {};
if (!requiredPolicy) { if (!requiredPolicy) {
@ -86,7 +89,10 @@ export const permissionGuard: CanActivateFn = (
let { requiredPolicy } = route.data || {}; let { requiredPolicy } = route.data || {};
if (!requiredPolicy) { if (!requiredPolicy) {
const routeFound = findRoute(routesService, routeCultureUrl.getRoutePathForMatching(router, state.url)); const routeFound = findRoute(
routesService,
routeCultureUrl.getRoutePathForMatching(router, state.url),
);
requiredPolicy = routeFound?.requiredPolicy; 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; this.routerState = routerState;
if (!routerState?.root) { if (!routerState?.root) {
return; return;
@ -41,7 +41,7 @@ export class AbpTitleStrategy extends TitleStrategy {
if (!title) { if (!title) {
return this.title.setTitle(projectName); return this.title.setTitle(projectName);
} }
let localizedText = this.localizationService.instant({ key: title, defaultValue: title }); let localizedText = this.localizationService.instant({ key: title, defaultValue: title });
if (!this.disableProjectName) { if (!this.disableProjectName) {
localizedText += ` | ${projectName}`; localizedText += ` | ${projectName}`;

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

@ -1,7 +1,7 @@
import { import {
ApplicationRef, ApplicationRef,
ComponentFactoryResolver,
ComponentRef, ComponentRef,
createComponent,
EmbeddedViewRef, EmbeddedViewRef,
Injector, Injector,
TemplateRef, TemplateRef,
@ -31,14 +31,10 @@ export class ComponentProjectionStrategy<T extends Type<any>> extends Projection
injectContent(injector: Injector) { injectContent(injector: Injector) {
this.containerStrategy.prepare(); this.containerStrategy.prepare();
const resolver = injector.get(ComponentFactoryResolver) as ComponentFactoryResolver; const componentRef = this.containerStrategy.containerRef.createComponent(this.content, {
const factory = resolver.resolveComponentFactory<InferredInstanceOf<T>>(this.content); index: this.containerStrategy.getIndex(),
const componentRef = this.containerStrategy.containerRef.createComponent(
factory,
this.containerStrategy.getIndex(),
injector, injector,
); });
this.contextStrategy.setContext(componentRef); this.contextStrategy.setContext(componentRef);
return componentRef as ComponentRefOrEmbeddedViewRef<T>; return componentRef as ComponentRefOrEmbeddedViewRef<T>;
@ -56,10 +52,10 @@ export class RootComponentProjectionStrategy<T extends Type<any>> extends Projec
injectContent(injector: Injector) { injectContent(injector: Injector) {
const appRef = injector.get(ApplicationRef); const appRef = injector.get(ApplicationRef);
const resolver = injector.get(ComponentFactoryResolver) as ComponentFactoryResolver; const componentRef = createComponent(this.content, {
const componentRef = resolver environmentInjector: appRef.injector,
.resolveComponentFactory<InferredInstanceOf<T>>(this.content) elementInjector: injector,
.create(injector); });
this.contextStrategy.setContext(componentRef); this.contextStrategy.setContext(componentRef);
@ -172,8 +168,9 @@ export const PROJECTION_STRATEGY = {
}, },
}; };
type ComponentRefOrEmbeddedViewRef<T> = T extends Type<infer U> type ComponentRefOrEmbeddedViewRef<T> =
? ComponentRef<U> T extends Type<infer U>
: T extends TemplateRef<infer C> ? ComponentRef<U>
? EmbeddedViewRef<C> : T extends TemplateRef<infer C>
: never; ? 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 { APP_BASE_HREF } from '@angular/common';
import { ModuleWithProviders, NgModule } from '@angular/core'; import { ModuleWithProviders, NgModule } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { provideRoutes } from '@angular/router'; import { provideRouter } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { MockPermissionService } from './services/mock-permission.service'; import { MockPermissionService } from './services/mock-permission.service';
import { MockRestService } from './services/mock-rest.service'; import { MockRestService } from './services/mock-rest.service';
@ -77,7 +77,7 @@ export class CoreTestingModule {
provide: SORT_COMPARE_FUNC, provide: SORT_COMPARE_FUNC,
useFactory: compareFuncFactory, useFactory: compareFuncFactory,
}, },
provideRoutes(routes), provideRouter(routes),
], ],
}; };
} }

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

@ -31,7 +31,7 @@
"executor": "@nx/vitest:test", "executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"], "outputs": ["{options.reportsDirectory}"],
"options": { "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 { 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'; import { FeatureManagementComponent } from '../feature-management/feature-management.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-feature-management-tab', selector: 'abp-feature-management-tab',
templateUrl: './feature-management-tab.component.html', templateUrl: './feature-management-tab.component.html',
imports: [ReplaceableTemplateDirective, LocalizationPipe, FeatureManagementComponent], 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) { @if (visible) {
<abp-modal [(visible)]="visible" [busy]="modalBusy" [options]="{ size: 'lg' }"> <abp-modal [(visible)]="visible" [busy]="modalBusy()" [options]="{ size: 'lg' }">
<ng-template #abpHeader> <ng-template #abpHeader>
<h3> <h3>
{{ 'AbpFeatureManagement::Features' | abpLocalization }} {{ 'AbpFeatureManagement::Features' | abpLocalization }}
@if (providerTitle()) { @if (providerTitle()) {
- {{ providerTitle() }} - {{ providerTitle() }}
} }
</h3> </h3>
</ng-template> </ng-template>
<ng-template #abpBody> <ng-template #abpBody>
<div class="row"> <div class="row">
@if (groups.length) { @if (groups().length) {
<div ngTabs orientation="vertical" class="row"> <div ngTabs orientation="vertical" class="row">
<div class="col-md-4"> <div class="col-md-4">
<div ngTabList orientation="vertical" selectionMode="follow" [(selectedTab)]="selectedGroupDisplayName" class="nav nav-pills flex-column"> <div
@for (group of groups; track group.name) { ngTabList
<button ngTab #tab="ngTab" type="button" [value]="group.displayName" class="nav-link text-start" [class.active]="tab.selected()"> 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 }} {{ group.displayName }}
</button> </button>
} }
@ -29,82 +42,106 @@
</ng-template> </ng-template>
<div class="col-md-8"> <div class="col-md-8">
@for (group of groups; track group.name) { @for (group of groups(); track group.name) {
<div ngTabPanel [value]="group.displayName"> <div ngTabPanel [value]="group.displayName">
<ng-template ngTabContent> <ng-template ngTabContent>
<h4>{{ selectedGroupDisplayName }}</h4> <h4>{{ selectedGroupDisplayName() }}</h4>
<hr class="mt-2 mb-3" /> <hr class="mt-2 mb-3" />
@for (feature of features[group.name]; track feature.id || i; let i = $index) { @for (
@let provider = feature.provider.name; feature of features()[group.name];
@let isFeatureDisabled = !feature.parentName ? isParentDisabled(feature.name, group.name, provider) : track feature.name || i;
(provider !== providerName() && provider !== defaultProviderName); 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()"> <div class="mt-2" [style]="feature.style" (keyup.enter)="save()">
@switch (feature.valueType?.name) { @switch (feature.valueType?.name) {
@case (valueTypes.ToggleStringValueType) { @case (valueTypes.ToggleStringValueType) {
<div class="form-check" [class.px-4]="!!feature.parentName"> <div class="form-check" [class.px-4]="!!feature.parentName">
<input class="form-check-input" type="checkbox" [id]="feature.name" [(ngModel)]="feature.value" <input
(ngModelChange)="onCheckboxClick($event, feature)" [disabled]="isFeatureDisabled" /> 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"> <label class="form-check-label" [htmlFor]="feature.name">
{{ feature.displayName }} {{ feature.displayName }}
@if (isFeatureDisabled) { @if (isFeatureDisabled) {
<span>({{ provider }})</span> <span>({{ provider }})</span>
} }
</label> </label>
<ng-container *ngTemplateOutlet=" <ng-container
*ngTemplateOutlet="
descTmp; descTmp;
context: { $implicit: feature.description } context: { $implicit: feature.description }
"></ng-container> "
</div> />
} </div>
@case (valueTypes.FreeTextStringValueType) { }
<div class="mb-3 form-group" [class.px-2]="!!feature.parentName"> @case (valueTypes.FreeTextStringValueType) {
<label [htmlFor]="feature.name" class="form-label"> <div class="mb-3 form-group" [class.px-2]="!!feature.parentName">
{{ feature.displayName }} <label [htmlFor]="feature.name" class="form-label">
@if (isFeatureDisabled) { {{ feature.displayName }}
<span>({{ provider }})</span> @if (isFeatureDisabled) {
} <span>({{ provider }})</span>
</label> }
<input class="form-control" type="text" [id]="feature.name" [(ngModel)]="feature.value" </label>
[abpFeatureManagementFreeText]="feature" [disabled]="isFeatureDisabled" /> <input
class="form-control"
type="text"
[id]="feature.name"
[(ngModel)]="feature.value"
[abpFeatureManagementFreeText]="feature"
[disabled]="isFeatureDisabled"
/>
<ng-container *ngTemplateOutlet=" <ng-container
*ngTemplateOutlet="
descTmp; descTmp;
context: { $implicit: feature.description } context: { $implicit: feature.description }
"></ng-container> "
</div> />
} </div>
@case (valueTypes.SelectionStringValueType) { }
@if (feature.valueType.itemSource?.items?.length) { @case (valueTypes.SelectionStringValueType) {
<div class="mb-3 form-group" [class.px-2]="!!feature.parentName"> @if (getSelectionItems(feature).length) {
<label [htmlFor]="feature.name" class="form-label"> <div class="mb-3 form-group" [class.px-2]="!!feature.parentName">
{{ feature.displayName }} <label [htmlFor]="feature.name" class="form-label">
@if (isFeatureDisabled) { {{ feature.displayName }}
<span>({{ provider }})</span> @if (isFeatureDisabled) {
} <span>({{ provider }})</span>
</label> }
<select class="form-select" [id]="feature.name" [(ngModel)]="feature.value" </label>
[disabled]="isFeatureDisabled"> <select
@for ( class="form-select"
item of feature.valueType.itemSource?.items; [id]="feature.name"
track item.value [(ngModel)]="feature.value"
) { [disabled]="isFeatureDisabled"
<option [ngValue]="item.value"> >
{{ @for (item of getSelectionItems(feature); track item.value) {
item.displayText?.resourceName + <option [ngValue]="item.value">
'::' + {{
item.displayText?.name | abpLocalization item.displayText?.resourceName +
}} '::' +
</option> item.displayText?.name | abpLocalization
} }}
</select> </option>
<ng-container *ngTemplateOutlet=" }
</select>
<ng-container
*ngTemplateOutlet="
descTmp; descTmp;
context: { $implicit: feature.description } context: { $implicit: feature.description }
" "
></ng-container> />
</div> </div>
} }
} }
@ -121,7 +158,7 @@
</div> </div>
} }
@if (!groups.length) { @if (!groups().length) {
<div class="col"> <div class="col">
{{ 'AbpFeatureManagement::NoFeatureFoundMessage' | abpLocalization }} {{ 'AbpFeatureManagement::NoFeatureFoundMessage' | abpLocalization }}
</div> </div>
@ -129,23 +166,32 @@
</div> </div>
</ng-template> </ng-template>
<ng-template #abpFooter> <ng-template #abpFooter>
<button abpClose type="button" class="btn btn-link"> <button abpClose type="button" class="btn btn-link">
{{ 'AbpFeatureManagement::Cancel' | abpLocalization }} {{ 'AbpFeatureManagement::Cancel' | abpLocalization }}
</button> </button>
@if (groups.length) { @if (groups().length) {
<abp-button buttonClass="btn btn-outline-primary" [disabled]="modalBusy" (click)="resetToDefault()" <abp-button
aria-hidden="true"> buttonClass="btn btn-outline-primary"
{{ 'AbpFeatureManagement::ResetToDefault' | abpLocalization }} [disabled]="modalBusy()"
</abp-button> (click)="resetToDefault()"
} aria-hidden="true"
>
{{ 'AbpFeatureManagement::ResetToDefault' | abpLocalization }}
</abp-button>
}
@if (groups.length) { @if (groups().length) {
<abp-button iconClass="fa fa-check" [disabled]="modalBusy" (click)="save()" aria-hidden="true"> <abp-button
{{ 'AbpFeatureManagement::Save' | abpLocalization }} iconClass="fa fa-check"
</abp-button> [disabled]="modalBusy()"
} (click)="save()"
</ng-template> aria-hidden="true"
</abp-modal> >
} {{ '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 { NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ConfigStateService, LocalizationPipe, TrackByService } from '@abp/ng.core'; import { ConfigStateService, LocalizationPipe, TrackByService } from '@abp/ng.core';
@ -29,7 +29,28 @@ enum ValueTypes {
const DEFAULT_PROVIDER_NAME = 'D'; 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({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-feature-management', selector: 'abp-feature-management',
templateUrl: './feature-management.component.html', templateUrl: './feature-management.component.html',
exportAs: 'abpFeatureManagement', exportAs: 'abpFeatureManagement',
@ -68,19 +89,19 @@ export class FeatureManagementComponent {
// Internal state // Internal state
protected readonly _visible = signal(false); 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: { protected readonly features = signal<{
[group: string]: Array<FeatureDto & { style?: { [key: string]: number }; initialValue: any }>; [group: string]: FeatureWithStyle[];
}; }>({});
valueTypes = ValueTypes; valueTypes = ValueTypes;
defaultProviderName = DEFAULT_PROVIDER_NAME; defaultProviderName = DEFAULT_PROVIDER_NAME;
modalBusy = false; protected readonly modalBusy = signal(false);
// Getter/setter for backward compatibility // Getter/setter for backward compatibility
get visible(): boolean { get visible(): boolean {
@ -124,25 +145,28 @@ export class FeatureManagementComponent {
getFeatures() { getFeatures() {
this.service.get(this.providerName()!, this.providerKey()).subscribe(res => { this.service.get(this.providerName()!, this.providerKey()).subscribe(res => {
if (!res.groups?.length) return; if (!res.groups?.length) return;
this.groups = res.groups.map(({ name, displayName }) => ({ name, displayName })); const groups = res.groups.map(({ name, displayName }) => ({ name, displayName }));
this.selectedGroupDisplayName = this.groups[0].displayName; this.groups.set(groups);
this.features = res.groups.reduce( this.selectedGroupDisplayName.set(groups[0].displayName);
(acc, val) => ({ this.features.set(
...acc, res.groups.reduce(
[val.name]: mapFeatures(val.features, this.document.body?.dir as LocaleDirection), (acc, val) => ({
}), ...acc,
{}, [val.name]: mapFeatures(val.features, this.document.body?.dir as LocaleDirection),
}),
{},
),
); );
}); });
} }
save() { save() {
if (this.modalBusy) return; if (this.modalBusy()) return;
const changedFeatures = [] as UpdateFeatureDto[]; const changedFeatures = [] as UpdateFeatureDto[];
Object.keys(this.features).forEach(key => { Object.keys(this.features()).forEach(key => {
this.features[key].forEach(feature => { this.features()[key].forEach(feature => {
if (feature.value !== feature.initialValue) if (feature.value !== feature.initialValue)
changedFeatures.push({ name: feature.name, value: `${feature.value}` }); changedFeatures.push({ name: feature.name, value: `${feature.value}` });
}); });
@ -153,10 +177,10 @@ export class FeatureManagementComponent {
return; return;
} }
this.modalBusy = true; this.modalBusy.set(true);
this.service this.service
.update(this.providerName()!, this.providerKey(), { features: changedFeatures }) .update(this.providerName()!, this.providerKey(), { features: changedFeatures })
.pipe(finalize(() => (this.modalBusy = false))) .pipe(finalize(() => this.modalBusy.set(false)))
.subscribe(() => { .subscribe(() => {
this.visible = false; 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 { 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(); const providerNameValue = this.providerName();
if (children?.length) { if (children?.length) {
@ -260,7 +292,8 @@ export class FeatureManagementComponent {
} }
private getCurrentGroup() { private getCurrentGroup() {
return this.features[this.selectedGroupDisplayName] ?? []; const selectedGroup = this.selectedGroupDisplayName();
return selectedGroup ? this.features()[selectedGroup] ?? [] : [];
} }
private setFeatureValue(feature: FeatureDto, val: boolean) { 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 { Directive, effect, inject, input, ElementRef, Renderer2 } from '@angular/core';
import type { FeatureDto } from '@abp/ng.feature-management/proxy';
// TODO: improve this type export type FreeTextType = Pick<FeatureDto, 'valueType'>;
export interface FreeTextType {
valueType: {
validator: {
name: string;
};
};
}
export const INPUT_TYPES: Record<string, string> = { export const INPUT_TYPES: Record<string, string> = {
numeric: 'number', numeric: 'number',

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

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

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

Loading…
Cancel
Save