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 diff --git a/docs/en/Entity-Framework-Core-Migrations.md b/docs/en/Entity-Framework-Core-Migrations.md index 716038d412..9ac7e76f24 100644 --- a/docs/en/Entity-Framework-Core-Migrations.md +++ b/docs/en/Entity-Framework-Core-Migrations.md @@ -173,7 +173,7 @@ First step is to change the connection string section inside all the `appsetting ````json "ConnectionStrings": { - "Default": "Server=(LocalDb)\MSSQLLocalDB;Database=BookStore;Trusted_Connection=True" + "Default": "Server=(LocalDb)\\MSSQLLocalDB;Database=BookStore;Trusted_Connection=True" } ```` @@ -184,7 +184,7 @@ Change it as shown below: "Default": "Server=(LocalDb)\\MSSQLLocalDB;Database=BookStore;Trusted_Connection=True", "AbpPermissionManagement": "Server=(LocalDb)\\MSSQLLocalDB;Database=BookStore_SecondDb;Trusted_Connection=True", "AbpSettingManagement": "Server=(LocalDb)\\MSSQLLocalDB;Database=BookStore_SecondDb;Trusted_Connection=True", - "AbpAuditLogging": "Server=(LocalDb)\MSSQLLocalDB;Database=BookStore_SecondDb;Trusted_Connection=True" + "AbpAuditLogging": "Server=(LocalDb)\\MSSQLLocalDB;Database=BookStore_SecondDb;Trusted_Connection=True" } ```` diff --git a/docs/en/Getting-Started-Running-Solution-Single-Layer.md b/docs/en/Getting-Started-Running-Solution-Single-Layer.md index cef80c7807..f18f3119f9 100644 --- a/docs/en/Getting-Started-Running-Solution-Single-Layer.md +++ b/docs/en/Getting-Started-Running-Solution-Single-Layer.md @@ -20,7 +20,7 @@ Check the **connection string** in the `appsettings.json` file under the `YourPr ````json "ConnectionStrings": { - "Default": "Server=(LocalDb)\MSSQLLocalDB;Database=BookStore;Trusted_Connection=True" + "Default": "Server=(LocalDb)\\MSSQLLocalDB;Database=BookStore;Trusted_Connection=True" } ```` diff --git a/docs/en/Getting-Started-Running-Solution.md b/docs/en/Getting-Started-Running-Solution.md index 36cd5ffe0a..f3adcac67d 100644 --- a/docs/en/Getting-Started-Running-Solution.md +++ b/docs/en/Getting-Started-Running-Solution.md @@ -21,7 +21,7 @@ Check the **connection string** in the `appsettings.json` file under the {{if Ti ````json "ConnectionStrings": { - "Default": "Server=(LocalDb)\MSSQLLocalDB;Database=BookStore;Trusted_Connection=True" + "Default": "Server=(LocalDb)\\MSSQLLocalDB;Database=BookStore;Trusted_Connection=True" } ```` diff --git a/docs/zh-Hans/Getting-Started-Running-Solution.md b/docs/zh-Hans/Getting-Started-Running-Solution.md index 8ecd85ec58..f0a4942d64 100644 --- a/docs/zh-Hans/Getting-Started-Running-Solution.md +++ b/docs/zh-Hans/Getting-Started-Running-Solution.md @@ -21,7 +21,7 @@ ````json "ConnectionStrings": { - "Default": "Server=(LocalDb)\MSSQLLocalDB;Database=BookStore;Trusted_Connection=True" + "Default": "Server=(LocalDb)\\MSSQLLocalDB;Database=BookStore;Trusted_Connection=True" } ```` diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/SolutionModuleAdder.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/SolutionModuleAdder.cs index 535b68ef59..ba85c48aeb 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/SolutionModuleAdder.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/SolutionModuleAdder.cs @@ -22,6 +22,7 @@ using Volo.Abp.Cli.Utils; using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus.Local; using Volo.Abp.Json; +using System.Text.RegularExpressions; namespace Volo.Abp.Cli.ProjectModification; @@ -114,12 +115,11 @@ public class SolutionModuleAdder : ITransientDependency var projectFiles = ProjectFinder.GetProjectFiles(solutionFile); await AddNugetAndNpmReferences(module, projectFiles, !(newTemplate || newProTemplate)); - + var modulesFolderInSolution = Path.Combine(Path.GetDirectoryName(solutionFile), "modules"); if (withSourceCode || newTemplate || newProTemplate) { - await PublishEventAsync(5, $"Downloading source code of {moduleName}"); await DownloadSourceCodesToSolutionFolder(module, modulesFolderInSolution, version, newTemplate, newProTemplate); @@ -147,6 +147,8 @@ public class SolutionModuleAdder : ITransientDependency else { await AddAngularPackages(solutionFile, module); + + await TryConfigureModuleConfigurationsForAngular(solutionFile, module); } await RunBundleForBlazorAsync(projectFiles, module); @@ -167,10 +169,83 @@ public class SolutionModuleAdder : ITransientDependency return module; } + private async Task TryConfigureModuleConfigurationsForAngular(string solutionFilePath, ModuleWithMastersInfo module) + { + var angularPath = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(solutionFilePath)), "angular"); + + if (!Directory.Exists(angularPath)) + { + return; + } + + var angularPackages = module.NpmPackages? + .Where(p => p.ApplicationType.HasFlag(NpmApplicationType.Angular)) + .ToList(); + + if (!angularPackages.Any()) + { + return; + } + + await PublishEventAsync(6, "Configuring angular projects..."); + + var moduleName = module.Name.Split('.').Last(); + + ConfigureAngularPackagesForAppModuleFile(angularPath, angularPackages, moduleName); + + ConfigureAngularPackagesForAppRoutingModuleFile(angularPath, angularPackages, moduleName); + } + + private void ConfigureAngularPackagesForAppModuleFile(string angularPath, List angularPackages, string moduleName) + { + var appModulePath = Path.Combine(angularPath, "src", "app", "app.module.ts"); + if (!File.Exists(appModulePath)) + { + return; + } + + var appModuleFileContent = File.ReadAllText(appModulePath); + + foreach (var angularPackage in angularPackages) + { + var moduleNameAsConfigPath = angularPackage.Name.EnsureStartsWith('@').EnsureEndsWith('/') + "config"; + + appModuleFileContent = "import { " + moduleName + "ConfigModule } from '" + moduleNameAsConfigPath + "';" + Environment.NewLine + appModuleFileContent; + appModuleFileContent = Regex.Replace(appModuleFileContent, "imports\\s*:\\s*\\[", + "imports: [" + Environment.NewLine + + " " + moduleName + "ConfigModule.forRoot(),"); + } + + File.WriteAllText(appModulePath, appModuleFileContent); + } + + private void ConfigureAngularPackagesForAppRoutingModuleFile(string angularPath, List angularPackages, string moduleName) + { + var appRoutingModulePath = Path.Combine(angularPath, "src", "app", "app-routing.module.ts"); + if (!File.Exists(appRoutingModulePath)) + { + return; + } + + var appRoutingModuleFileContent = File.ReadAllText(appRoutingModulePath); + + foreach (var angularPackage in angularPackages) + { + appRoutingModuleFileContent = Regex.Replace(appRoutingModuleFileContent, "Routes\\s*=\\s*\\[", + "Routes = [" + Environment.NewLine + + " " + "{" + Environment.NewLine + + " " + "path: '" + moduleName.ToLower() + "'," + Environment.NewLine + + " " + "loadChildren: () => " + $"import('{angularPackage.Name.EnsureStartsWith('@')}').then(m => m.{moduleName}Module.forLazy())," + Environment.NewLine + + " " + "},"); + } + + File.WriteAllText(appRoutingModulePath, appRoutingModuleFileContent); + } + private async Task SetLeptonXAbpVersionsAsync(string solutionFile, string combine) { var abpVersion = SolutionPackageVersionFinder.FindByCsprojVersion(solutionFile); - + var projects = Directory.GetFiles(Path.GetDirectoryName(solutionFile)!, "*.csproj", SearchOption.AllDirectories); foreach (var project in projects) @@ -183,7 +258,8 @@ public class SolutionModuleAdder : ITransientDependency private async Task PublishEventAsync(int currentStep, string message) { - await LocalEventBus.PublishAsync(new ModuleInstallingProgressEvent { + await LocalEventBus.PublishAsync(new ModuleInstallingProgressEvent + { CurrentStep = currentStep, Message = message }, false); @@ -562,7 +638,7 @@ public class SolutionModuleAdder : ITransientDependency if (webPackagesWillBeAddedToBlazorServerProject) { - if ( nugetTarget == NuGetPackageTarget.Web) + if (nugetTarget == NuGetPackageTarget.Web) { nugetTarget = NuGetPackageTarget.BlazorServer; } @@ -636,7 +712,7 @@ public class SolutionModuleAdder : ITransientDependency } var dbMigrationsProject = projectFiles.FirstOrDefault(p => p.EndsWith(".DbMigrations.csproj")) - ?? projectFiles.FirstOrDefault(p => p.EndsWith(".EntityFrameworkCore.csproj")) ; + ?? projectFiles.FirstOrDefault(p => p.EndsWith(".EntityFrameworkCore.csproj")); if (dbMigrationsProject == null) { diff --git a/modules/cms-kit/src/Volo.CmsKit.Admin.Web/Pages/CmsKit/Tags/Components/TagEditor/Default.cshtml b/modules/cms-kit/src/Volo.CmsKit.Admin.Web/Pages/CmsKit/Tags/Components/TagEditor/Default.cshtml index 3232be484d..b4695840cc 100644 --- a/modules/cms-kit/src/Volo.CmsKit.Admin.Web/Pages/CmsKit/Tags/Components/TagEditor/Default.cshtml +++ b/modules/cms-kit/src/Volo.CmsKit.Admin.Web/Pages/CmsKit/Tags/Components/TagEditor/Default.cshtml @@ -11,6 +11,7 @@
+
@L["TagsHelpText"]
@if (Model.DisplaySubmitButton) diff --git a/modules/cms-kit/src/Volo.CmsKit.Domain.Shared/Volo/CmsKit/Localization/Resources/en.json b/modules/cms-kit/src/Volo.CmsKit.Domain.Shared/Volo/CmsKit/Localization/Resources/en.json index 4555ed36b1..327f924753 100644 --- a/modules/cms-kit/src/Volo.CmsKit.Domain.Shared/Volo/CmsKit/Localization/Resources/en.json +++ b/modules/cms-kit/src/Volo.CmsKit.Domain.Shared/Volo/CmsKit/Localization/Resources/en.json @@ -215,10 +215,14 @@ "DeleteBlogPostMessage": "The blog will be deleted. Are you sure?", "CaptchaCode": "Captcha code", "CommentTextRequired": "Comment is required", - "CaptchaCodeErrorMessage" : "The answer you entered for the CAPTCHA was not correct. Please try again", + "CaptchaCodeErrorMessage": "The answer you entered for the CAPTCHA was not correct. Please try again", "CaptchaCodeMissingMessage": "The captcha code is missing!", "UnAllowedExternalUrlMessage": "You included an unallowed external URL. Please try again without the external URL.", "URL": "URL", - "PopularTags": "Popular Tags" + "PopularTags": "Popular Tags", + "RemoveCoverImageConfirmationMessage": "Are you sure you want to remove the cover image?", + "RemoveCoverImage": "Remove cover image", + "CssClass": "CSS Class", + "TagsHelpText": "Tags should be comma-separated (e.g.: tag1, tag2, tag3)" } } \ No newline at end of file