mirror of https://github.com/abpframework/abp.git
13 changed files with 544 additions and 13 deletions
@ -0,0 +1,450 @@ |
|||||
|
# Cascading Option Loading with Extensions System in ABP Angular |
||||
|
|
||||
|
This article will show how to load cascading options with an extensions system in ABP Angular. For this example, we'll simulate renting a book process. Besides our default form properties, we'll contribute `Name` property to our `Rent Form Modal` in the Books module. This property will be loaded after `Genre` is selected. |
||||
|
|
||||
|
> Before starting this article, I suggest you read the [ABP Angular Dynamic Form Extensions](https://docs.abp.io/en/abp/latest/UI/Angular/Dynamic-Form-Extensions) |
||||
|
|
||||
|
### Environment |
||||
|
|
||||
|
- **ABP Framework Version:** ~7.3.0 (`~` means that use the latest patch version of the specified release) |
||||
|
- **DB Provider:** MongoDB |
||||
|
- **Angular Version:** ~16.0.0 |
||||
|
|
||||
|
### Project structure |
||||
|
|
||||
|
The books module is not a library; for this demo, it'll placed in the application itself. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
- **books folder:** Contains default form properties, tokens, models, etc. It's similar to the ABP module structure. |
||||
|
- Also I've used **standalone** and **signals** feature in this demo. |
||||
|
- **books-extended folder:** Contains only `Name` property for the contribute `Rent Form Modal` inside the Books module. |
||||
|
- **For more readability, I've used TS path aliases in this demo. Don't forget to export files in `index.ts` file 🙂** |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### First look at the demo |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### What is the Extension system? |
||||
|
|
||||
|
 |
||||
|
|
||||
|
# Reviewing the code step by step |
||||
|
|
||||
|
**1. Create default form properties for `Rent Form` in the `Books` module** |
||||
|
|
||||
|
- `getInjected` function is the key point of the cascading loading |
||||
|
- We can reach and track any value from `Service` or `Component` |
||||
|
- In that way we can load options according to the selected value |
||||
|
|
||||
|
```ts |
||||
|
// ~/books/defaults/default-books-form.props.ts |
||||
|
|
||||
|
import { Validators } from "@angular/forms"; |
||||
|
import { map, of } from "rxjs"; |
||||
|
import { ePropType, FormProp } from "@abp/ng.theme.shared/extensions"; |
||||
|
import { BookDto, AuthorService, BooksService } from "../proxy"; |
||||
|
import { RentBookComponent } from "../components"; |
||||
|
import { DefaultOption } from "../utils"; |
||||
|
|
||||
|
const { required } = Validators; |
||||
|
|
||||
|
export const DEFAULT_RENT_FORM_PROPS = FormProp.createMany<BookDto>([ |
||||
|
{ |
||||
|
type: ePropType.String, |
||||
|
id: "authorId", |
||||
|
name: "authorId", |
||||
|
displayName: "BookStore::Author", |
||||
|
defaultValue: null, |
||||
|
validators: () => [required], |
||||
|
options: (data) => { |
||||
|
const { authors } = data.getInjected(AuthorService); |
||||
|
|
||||
|
return of([ |
||||
|
DefaultOption, |
||||
|
...authors().map((author) => ({ value: author.id, key: author.name })), |
||||
|
]); |
||||
|
}, |
||||
|
}, |
||||
|
{ |
||||
|
type: ePropType.String, |
||||
|
id: "genreId", |
||||
|
name: "genreId", |
||||
|
displayName: "BookStore::Genre", |
||||
|
defaultValue: null, |
||||
|
validators: () => [required], |
||||
|
options: (data) => { |
||||
|
const rentBookComponent = data.getInjected(RentBookComponent); |
||||
|
const { genres } = data.getInjected(BooksService); |
||||
|
|
||||
|
const genreOptions = genres().map(({ id, name }) => ({ |
||||
|
value: id, |
||||
|
key: name, |
||||
|
})); |
||||
|
|
||||
|
return rentBookComponent.form.controls.authorId.valueChanges.pipe( |
||||
|
map((value: string | undefined) => |
||||
|
value ? [DefaultOption, ...genreOptions] : [DefaultOption] |
||||
|
) |
||||
|
); |
||||
|
}, |
||||
|
}, |
||||
|
{ |
||||
|
type: ePropType.Date, |
||||
|
id: "returnDate", |
||||
|
name: "returnDate", |
||||
|
displayName: "BookStore::ReturnDate", |
||||
|
defaultValue: null, |
||||
|
validators: () => [required], |
||||
|
}, |
||||
|
]); |
||||
|
``` |
||||
|
|
||||
|
**2. Configure tokens and config options** |
||||
|
|
||||
|
The documentation explains these steps; that's why I won't explain it again. If documents or samples are not enough, please let me know in the comments 🙂 |
||||
|
|
||||
|
**Extensions Token** |
||||
|
|
||||
|
```ts |
||||
|
// ~/books/tokens/extensions.token.ts |
||||
|
|
||||
|
import { CreateFormPropContributorCallback } from "@abp/ng.theme.shared/extensions"; |
||||
|
import { InjectionToken } from "@angular/core"; |
||||
|
import { BookDto } from "../proxy"; |
||||
|
import { eBooksComponents } from "../enums"; |
||||
|
import { DEFAULT_RENT_FORM_PROPS } from "../defaults"; |
||||
|
|
||||
|
export const DEFAULT_BOOK_STORE_CREATE_FORM_PROPS = { |
||||
|
[eBooksComponents.RentBook]: DEFAULT_RENT_FORM_PROPS, |
||||
|
}; |
||||
|
|
||||
|
export const BOOK_STORE_RENT_FORM_PROP_CONTRIBUTORS = |
||||
|
new InjectionToken<CreateFormPropContributors>( |
||||
|
"BOOK_STORE_RENT_FORM_PROP_CONTRIBUTORS" |
||||
|
); |
||||
|
|
||||
|
type CreateFormPropContributors = Partial<{ |
||||
|
[eBooksComponents.RentBook]: CreateFormPropContributorCallback<BookDto>[]; |
||||
|
/** |
||||
|
* Other creation form prop contributors... |
||||
|
*/ |
||||
|
// [eBooksComponents.CreateBook]: CreateFormPropContributorCallback<BookDto>[]; |
||||
|
}>; |
||||
|
``` |
||||
|
|
||||
|
**Extensions Config Option** |
||||
|
|
||||
|
```ts |
||||
|
// ~/books/models/config-options.ts |
||||
|
|
||||
|
import { CreateFormPropContributorCallback } from "@abp/ng.theme.shared/extensions"; |
||||
|
import { BookDto } from "../proxy"; |
||||
|
import { eBooksComponents } from "../enums"; |
||||
|
|
||||
|
export type BookStoreRentFormPropContributors = Partial<{ |
||||
|
[eBooksComponents.RentBook]: CreateFormPropContributorCallback<BookDto>[]; |
||||
|
}>; |
||||
|
|
||||
|
export interface BooksConfigOptions { |
||||
|
rentFormPropContributors?: BookStoreRentFormPropContributors; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**3. Extensions Guard** |
||||
|
|
||||
|
It'll to collect all contributors from [ExtensionsService](https://github.com/abpframework/abp/blob/dev/npm/ng-packs/packages/theme-shared/extensions/src/lib/services/extensions.service.ts) |
||||
|
|
||||
|
```ts |
||||
|
// ~/books/guards/extensions.guard.ts |
||||
|
|
||||
|
import { Injectable, inject } from "@angular/core"; |
||||
|
import { Observable, map, tap } from "rxjs"; |
||||
|
import { ConfigStateService, IAbpGuard } from "@abp/ng.core"; |
||||
|
import { |
||||
|
ExtensionsService, |
||||
|
getObjectExtensionEntitiesFromStore, |
||||
|
mapEntitiesToContributors, |
||||
|
mergeWithDefaultProps, |
||||
|
} from "@abp/ng.theme.shared/extensions"; |
||||
|
import { |
||||
|
BOOK_STORE_RENT_FORM_PROP_CONTRIBUTORS, |
||||
|
DEFAULT_BOOK_STORE_CREATE_FORM_PROPS, |
||||
|
} from "../tokens"; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class BooksExtensionsGuard implements IAbpGuard { |
||||
|
protected readonly configState = inject(ConfigStateService); |
||||
|
protected readonly extensions = inject(ExtensionsService); |
||||
|
|
||||
|
canActivate(): Observable<boolean> { |
||||
|
const createFormContributors = |
||||
|
inject(BOOK_STORE_RENT_FORM_PROP_CONTRIBUTORS, { optional: true }) || {}; |
||||
|
|
||||
|
return getObjectExtensionEntitiesFromStore( |
||||
|
this.configState, |
||||
|
"BookStore" |
||||
|
).pipe( |
||||
|
mapEntitiesToContributors(this.configState, "BookStore"), |
||||
|
tap((objectExtensionContributors) => { |
||||
|
mergeWithDefaultProps( |
||||
|
this.extensions.createFormProps, |
||||
|
DEFAULT_BOOK_STORE_CREATE_FORM_PROPS, |
||||
|
objectExtensionContributors.createForm, |
||||
|
createFormContributors |
||||
|
); |
||||
|
}), |
||||
|
map(() => true) |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Yes, I'm still using class-based guard 🙂 much more flexible... |
||||
|
|
||||
|
**4. RentBookComponent** |
||||
|
|
||||
|
- Our trackable variable is defined here `(form:FormGroup)`, which means We'll track this variable in `options` property at defaults || contributors files. |
||||
|
- Providing `AuthorService`, also `EXTENSIONS_IDENTIFIER` for the reach dynamic properties |
||||
|
|
||||
|
```ts |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
EventEmitter, |
||||
|
Injector, |
||||
|
Output, |
||||
|
inject, |
||||
|
} from "@angular/core"; |
||||
|
import { FormGroup } from "@angular/forms"; |
||||
|
import { CoreModule, uuid } from "@abp/ng.core"; |
||||
|
import { ThemeSharedModule } from "@abp/ng.theme.shared"; |
||||
|
import { |
||||
|
EXTENSIONS_IDENTIFIER, |
||||
|
FormPropData, |
||||
|
UiExtensionsModule, |
||||
|
generateFormFromProps, |
||||
|
} from "@abp/ng.theme.shared/extensions"; |
||||
|
import { AuthorService, BookDto, BooksService } from "../../proxy"; |
||||
|
import { eBooksComponents } from "../../enums"; |
||||
|
|
||||
|
@Component({ |
||||
|
standalone: true, |
||||
|
selector: "app-rent-book", |
||||
|
templateUrl: "./rent-book.component.html", |
||||
|
imports: [CoreModule, UiExtensionsModule, ThemeSharedModule], |
||||
|
providers: [ |
||||
|
{ |
||||
|
provide: EXTENSIONS_IDENTIFIER, |
||||
|
useValue: eBooksComponents.RentBook, |
||||
|
}, |
||||
|
AuthorService, |
||||
|
], |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
}) |
||||
|
export class RentBookComponent { |
||||
|
protected readonly injector = inject(Injector); |
||||
|
protected readonly authorService = inject(AuthorService); |
||||
|
protected readonly booksService = inject(BooksService); |
||||
|
|
||||
|
//#region Just for demo |
||||
|
readonly #authors = this.authorService.authors(); |
||||
|
readonly #genres = this.booksService.genres(); |
||||
|
readonly #books = this.booksService.books(); |
||||
|
//#endregion |
||||
|
|
||||
|
protected modalVisible = true; |
||||
|
@Output() modalVisibleChange = new EventEmitter<boolean>(); |
||||
|
|
||||
|
selected: BookDto; |
||||
|
form: FormGroup; |
||||
|
modalBusy = false; |
||||
|
|
||||
|
protected buildForm(): void { |
||||
|
const data = new FormPropData(this.injector, this.selected); |
||||
|
this.form = generateFormFromProps(data); |
||||
|
} |
||||
|
|
||||
|
constructor() { |
||||
|
this.buildForm(); |
||||
|
} |
||||
|
|
||||
|
save(): void { |
||||
|
if (this.form.invalid) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.modalBusy = true; |
||||
|
|
||||
|
const { authorId, genreId, bookId, returnDate } = this.form.value; |
||||
|
|
||||
|
//#region Just for demo |
||||
|
const authorName = this.#authors.find(({ id }) => id === authorId).name; |
||||
|
const genreName = this.#genres.find(({ id }) => id === genreId).name; |
||||
|
const bookName = this.#books.find(({ id }) => id === bookId).name; |
||||
|
//#endregion |
||||
|
|
||||
|
this.booksService.rentedBooks.update((books) => [ |
||||
|
{ |
||||
|
id: uuid(), |
||||
|
name: bookName, |
||||
|
author: authorName, |
||||
|
genre: genreName, |
||||
|
returnDate, |
||||
|
}, |
||||
|
...books, |
||||
|
]); |
||||
|
|
||||
|
this.modalBusy = false; |
||||
|
this.modalVisible = false; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
```html |
||||
|
<abp-modal |
||||
|
[visible]="modalVisible" |
||||
|
[busy]="modalBusy" |
||||
|
(visibleChange)="modalVisibleChange.next($event)" |
||||
|
> |
||||
|
<ng-template #abpHeader> |
||||
|
<h3>{{ 'BookStore::RentABook' | abpLocalization }}</h3> |
||||
|
</ng-template> |
||||
|
|
||||
|
<ng-template #abpBody> |
||||
|
<ng-template #loaderRef> |
||||
|
<div class="text-center"> |
||||
|
<i class="fa fa-pulse fa-spinner" aria-hidden="true"></i> |
||||
|
</div> |
||||
|
</ng-template> |
||||
|
|
||||
|
<form |
||||
|
*ngIf="form; else loaderRef" |
||||
|
[formGroup]="form" |
||||
|
(ngSubmit)="save()" |
||||
|
validateOnSubmit |
||||
|
> |
||||
|
<abp-extensible-form [selectedRecord]="selected"></abp-extensible-form> |
||||
|
</form> |
||||
|
</ng-template> |
||||
|
|
||||
|
<ng-template #abpFooter> |
||||
|
<button abpClose type="button" class="btn btn-secondary"> |
||||
|
{{ 'AbpIdentity::Cancel' | abpLocalization }} |
||||
|
</button> |
||||
|
<abp-button |
||||
|
iconClass="fa fa-check" |
||||
|
[disabled]="form?.invalid" |
||||
|
(click)="save()" |
||||
|
> |
||||
|
{{ 'AbpIdentity::Save' | abpLocalization }} |
||||
|
</abp-button> |
||||
|
</ng-template> |
||||
|
</abp-modal> |
||||
|
``` |
||||
|
|
||||
|
Up to now, we have constructed our module's default form properties. |
||||
|
|
||||
|
- As you can see, there are no book names we'll add them via contributors |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## Next, add new property dynamically (book name list as dropdown) |
||||
|
|
||||
|
- Created new folder ./src/app/books-extended |
||||
|
- Create contributors/form-prop.contributors.ts |
||||
|
|
||||
|
```ts |
||||
|
// ~/books-extened/contributors/form-prop.contributors.ts |
||||
|
|
||||
|
import { Validators } from "@angular/forms"; |
||||
|
import { map } from "rxjs"; |
||||
|
import { |
||||
|
ePropType, |
||||
|
FormProp, |
||||
|
FormPropList, |
||||
|
} from "@abp/ng.theme.shared/extensions"; |
||||
|
import { |
||||
|
BookDto, |
||||
|
BookStoreRentFormPropContributors, |
||||
|
BooksService, |
||||
|
DefaultOption, |
||||
|
RentBookComponent, |
||||
|
eBooksComponents, |
||||
|
} from "@book-store/books"; |
||||
|
|
||||
|
const { required, maxLength } = Validators; |
||||
|
|
||||
|
const bookIdProp = new FormProp<BookDto>({ |
||||
|
type: ePropType.String, |
||||
|
id: "bookId", |
||||
|
name: "bookId", |
||||
|
displayName: "BookStore::Name", |
||||
|
options: (data) => { |
||||
|
const rentBook = data.getInjected(RentBookComponent); |
||||
|
const { books } = data.getInjected(BooksService); |
||||
|
const bookOptions = books().map(({ id, name }) => ({ |
||||
|
value: id, |
||||
|
key: name, |
||||
|
})); |
||||
|
|
||||
|
return rentBook.form.controls.genreId.valueChanges.pipe( |
||||
|
map((value: string | undefined) => |
||||
|
value ? [DefaultOption, ...bookOptions] : [DefaultOption] |
||||
|
) |
||||
|
); |
||||
|
}, |
||||
|
validators: () => [required, maxLength(255)], |
||||
|
}); |
||||
|
|
||||
|
export function bookIdPropContributor(propList: FormPropList<BookDto>) { |
||||
|
propList.addByIndex(bookIdProp, 2); |
||||
|
} |
||||
|
|
||||
|
export const bookStoreRentFormPropContributors: BookStoreRentFormPropContributors = |
||||
|
{ |
||||
|
[eBooksComponents.RentBook]: [bookIdPropContributor], |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
- Load new contributions via routing & forLazy method |
||||
|
|
||||
|
```ts |
||||
|
// ~/app-routing.module.ts |
||||
|
import { bookStoreRentFormPropContributors } from "./books-extended/contributors/form-prop.contributors"; |
||||
|
|
||||
|
const routes: Routes = [ |
||||
|
// other routes... |
||||
|
{ |
||||
|
path: "books", |
||||
|
loadChildren: () => |
||||
|
import("@book-store/books").then((m) => |
||||
|
m.BooksModule.forLazy({ |
||||
|
rentFormPropContributors: bookStoreRentFormPropContributors, |
||||
|
}) |
||||
|
), |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
@NgModule({ |
||||
|
imports: [RouterModule.forRoot(routes, {})], |
||||
|
exports: [RouterModule], |
||||
|
}) |
||||
|
export class AppRoutingModule {} |
||||
|
``` |
||||
|
|
||||
|
Finally, we've added a new property to our module, and it'll be loaded after `Genre` is selected. |
||||
|
|
||||
|
## Conclusion |
||||
|
|
||||
|
 |
||||
|
|
||||
|
- In ABP Angular, we can create form properties and load dropdown options dynamically via the Extensions System |
||||
|
- We can reach and track any value from `Service` or `Component` |
||||
|
- We can create our custom library or module and contribute it to any module in the application |
||||
|
|
||||
|
Thanks for reading, I hope it was helpful. If you have any questions, please let me know in the comments section. 👋👋 |
||||
|
|
||||
|
> You can find the source code of this article on [Github](https://github.com/abpframework/abp-samples/tree/master/AngularCascadingOptionLoading/Volo.BookStore) |
||||
|
After Width: | Height: | Size: 8.5 MiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 58 KiB |
Loading…
Reference in new issue