Browse Source

Merge branch 'dev' into AdditionalEmailSendingArgs

pull/17582/head
Halil İbrahim Kalkan 3 years ago
parent
commit
d776e3ad71
  1. 450
      docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/README.md
  2. BIN
      docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/gif/cascading-loading-demo.gif
  3. BIN
      docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/extensions-system-document.png
  4. BIN
      docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/folder-structure.png
  5. BIN
      docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/rent-form-without-contribution.png
  6. BIN
      docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/ts-config-file.png
  7. 4
      docs/en/Entity-Framework-Core-Migrations.md
  8. 2
      docs/en/Getting-Started-Running-Solution-Single-Layer.md
  9. 2
      docs/en/Getting-Started-Running-Solution.md
  10. 2
      docs/zh-Hans/Getting-Started-Running-Solution.md
  11. 88
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/SolutionModuleAdder.cs
  12. 1
      modules/cms-kit/src/Volo.CmsKit.Admin.Web/Pages/CmsKit/Tags/Components/TagEditor/Default.cshtml
  13. 8
      modules/cms-kit/src/Volo.CmsKit.Domain.Shared/Volo/CmsKit/Localization/Resources/en.json

450
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<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
![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<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
![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)

BIN
docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/gif/cascading-loading-demo.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

BIN
docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/extensions-system-document.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/folder-structure.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/rent-form-without-contribution.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/en/Community-Articles/2023-07-09-Cascading-Option-Loading-With-Extensions/assets/img/ts-config-file.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

4
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"
}
````

2
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"
}
````

2
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"
}
````

2
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"
}
````

88
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<NpmPackageInfo> 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<NpmPackageInfo> 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)
{

1
modules/cms-kit/src/Volo.CmsKit.Admin.Web/Pages/CmsKit/Tags/Components/TagEditor/Default.cshtml

@ -11,6 +11,7 @@
<div class="mb-3">
<label class="form-label">@L["Tags"]</label>
<input name="tags" value="@string.Join(',', Model.Tags.Select(s => s.Name))" class="form-control"/>
<div class="form-text">@L["TagsHelpText"]</div>
</div>
@if (Model.DisplaySubmitButton)

8
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)"
}
}
Loading…
Cancel
Save