diff --git a/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/README.md b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/README.md new file mode 100644 index 0000000000..2db041436c --- /dev/null +++ b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/README.md @@ -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. + +![Folder structure](./assets/img/folder-structure.png) + +- **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 🙂** + +![tsconfig.json file](./assets/img/ts-config-file.png) + +### First look at the demo + +![Cascading Loading Demo](assets/gif/cascading-loading-demo.gif) + +### What is the Extension system? + +![Extensions System Document](./assets/img/extensions-system-document.png) + +# 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([ + { + 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( + "BOOK_STORE_RENT_FORM_PROP_CONTRIBUTORS" + ); + +type CreateFormPropContributors = Partial<{ + [eBooksComponents.RentBook]: CreateFormPropContributorCallback[]; + /** + * Other creation form prop contributors... + */ + // [eBooksComponents.CreateBook]: CreateFormPropContributorCallback[]; +}>; +``` + +**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[]; +}>; + +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 { + 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(); + + 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 + + +

{{ 'BookStore::RentABook' | abpLocalization }}

+
+ + + +
+ +
+
+ +
+ +
+
+ + + + + {{ 'AbpIdentity::Save' | abpLocalization }} + + +
+``` + +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 + +![Rent Form Without Contribution](./assets/img/rent-form-without-contribution.png) + +## 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({ + 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) { + 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 + +![Cascading Loading Demo](assets/gif/cascading-loading-demo.gif) + +- 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) diff --git a/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/gif/cascading-loading-demo.gif b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/gif/cascading-loading-demo.gif new file mode 100644 index 0000000000..ae69070f28 Binary files /dev/null and b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/gif/cascading-loading-demo.gif differ diff --git a/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/extensions-system-document.png b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/extensions-system-document.png new file mode 100644 index 0000000000..7f806ec42d Binary files /dev/null and b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/extensions-system-document.png differ diff --git a/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/folder-structure.png b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/folder-structure.png new file mode 100644 index 0000000000..2d3a828773 Binary files /dev/null and b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/folder-structure.png differ diff --git a/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/rent-form-without-contribution.png b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/rent-form-without-contribution.png new file mode 100644 index 0000000000..e29d525f7b Binary files /dev/null and b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/rent-form-without-contribution.png differ diff --git a/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/ts-config-file.png b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/ts-config-file.png new file mode 100644 index 0000000000..6b06e8ca90 Binary files /dev/null and b/docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/ts-config-file.png differ