|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
@ -1,3 +1,178 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Learn how the ABP Framework uses Correlation IDs to trace and correlate operations across HTTP requests, distributed events, audit logs, and microservices." |
|||
} |
|||
``` |
|||
|
|||
# Correlation ID |
|||
|
|||
This document is planned to be written later. |
|||
A **Correlation ID** is a unique identifier that is assigned to a request or operation and propagated across all related processing steps. It allows you to **trace** and **correlate** logs, events, and operations that belong to the same logical transaction, even when they span multiple services or components. |
|||
|
|||
ABP provides a built-in correlation ID infrastructure that: |
|||
|
|||
- **Automatically assigns** a unique correlation ID to each incoming HTTP request (or uses the one provided by the caller). |
|||
- **Propagates** the correlation ID through distributed event bus messages, HTTP client calls, audit logs, security logs, and Serilog log entries. |
|||
- **Provides a simple API** (`ICorrelationIdProvider`) to get or change the current correlation ID in your application code. |
|||
|
|||
## `AbpCorrelationIdMiddleware` |
|||
|
|||
`AbpCorrelationIdMiddleware` is an ASP.NET Core middleware that handles correlation ID management for HTTP requests. It is automatically added to the request pipeline when you use ABP's application builder. |
|||
|
|||
The middleware performs the following steps for each incoming HTTP request: |
|||
|
|||
1. **Reads** the correlation ID from the incoming request's `X-Correlation-Id` header (configurable via `AbpCorrelationIdOptions` as explained below). |
|||
2. **Generates** a new correlation ID (`Guid.NewGuid().ToString("N")`) if the request does not contain one. |
|||
3. **Sets** the correlation ID in the current async context using `ICorrelationIdProvider`, making it available throughout the request pipeline. |
|||
4. **Writes** the correlation ID to the response header (if `SetResponseHeader` option is enabled). |
|||
|
|||
You can add the middleware to your request pipeline by calling the `UseCorrelationId` extension method: |
|||
|
|||
```csharp |
|||
app.UseCorrelationId(); |
|||
``` |
|||
|
|||
> This is already configured in the application startup template. You typically don't need to add it manually. |
|||
|
|||
## `ICorrelationIdProvider` |
|||
|
|||
`ICorrelationIdProvider` is the core service for working with correlation IDs. It allows you to retrieve the current correlation ID or temporarily change it. |
|||
|
|||
```csharp |
|||
public interface ICorrelationIdProvider |
|||
{ |
|||
string? Get(); |
|||
|
|||
IDisposable Change(string? correlationId); |
|||
} |
|||
``` |
|||
|
|||
- `Get()`: Returns the current correlation ID for the executing context. Returns `null` if no correlation ID has been set. |
|||
- `Change(string? correlationId)`: Changes the correlation ID for the current context and returns an `IDisposable` object. When the returned object is disposed, the correlation ID is restored to its previous value. |
|||
|
|||
### Using `ICorrelationIdProvider` |
|||
|
|||
You can inject `ICorrelationIdProvider` into any service to access the current correlation ID: |
|||
|
|||
```csharp |
|||
public class MyService : ITransientDependency |
|||
{ |
|||
public ILogger<MyService> Logger { get; set; } |
|||
|
|||
private readonly ICorrelationIdProvider _correlationIdProvider; |
|||
|
|||
public MyService(ICorrelationIdProvider correlationIdProvider) |
|||
{ |
|||
Logger = NullLogger<MyService>.Instance; |
|||
_correlationIdProvider = correlationIdProvider; |
|||
} |
|||
|
|||
public async Task DoSomethingAsync() |
|||
{ |
|||
// Get the current correlation ID |
|||
var correlationId = _correlationIdProvider.Get(); |
|||
|
|||
// Use it for logging, tracing, etc. |
|||
Logger.LogInformation("Processing with Correlation ID: {CorrelationId}", correlationId); |
|||
|
|||
await SomeOperationAsync(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Changing the Correlation ID |
|||
|
|||
You can temporarily change the correlation ID using the `Change` method. This is useful when you want to create a new scope with a different correlation ID: |
|||
|
|||
```csharp |
|||
public async Task ProcessAsync() |
|||
{ |
|||
var currentCorrelationId = _correlationIdProvider.Get(); |
|||
// currentCorrelationId = "abc123" |
|||
|
|||
using (_correlationIdProvider.Change("new-correlation-id")) |
|||
{ |
|||
var innerCorrelationId = _correlationIdProvider.Get(); |
|||
// innerCorrelationId = "new-correlation-id" |
|||
} |
|||
|
|||
var restoredCorrelationId = _correlationIdProvider.Get(); |
|||
// restoredCorrelationId = "abc123" (restored to original) |
|||
} |
|||
``` |
|||
|
|||
The `Change` method returns an `IDisposable`. When disposed, the correlation ID is automatically restored to its previous value. This pattern supports nested scopes safely. |
|||
|
|||
### Default Implementation |
|||
|
|||
The default implementation (`DefaultCorrelationIdProvider`) uses `AsyncLocal<string?>` to store the correlation ID. This ensures that the correlation ID is isolated per async execution flow and is thread-safe. |
|||
|
|||
## `AbpCorrelationIdOptions` |
|||
|
|||
You can configure the correlation ID behavior using `AbpCorrelationIdOptions`: |
|||
|
|||
```csharp |
|||
Configure<AbpCorrelationIdOptions>(options => |
|||
{ |
|||
options.HttpHeaderName = "X-Correlation-Id"; |
|||
options.SetResponseHeader = true; |
|||
}); |
|||
``` |
|||
|
|||
- `HttpHeaderName` (default: `"X-Correlation-Id"`): The HTTP header name used to read/write the correlation ID. You can change this if your infrastructure uses a different header name. |
|||
- `SetResponseHeader` (default: `true`): If `true`, the middleware automatically adds the correlation ID to the HTTP response headers. Set it to `false` if you don't want to expose the correlation ID in response headers. |
|||
|
|||
## Correlation ID Across ABP Services |
|||
|
|||
One of the most valuable aspects of ABP's correlation ID infrastructure is that it **automatically propagates** the correlation ID across various system boundaries. This allows you to trace a single user action as it flows through multiple services and components. |
|||
|
|||
### HTTP Client Calls |
|||
|
|||
When you use ABP's [dynamic client proxies](../api-development/dynamic-csharp-clients.md) to call remote services, the correlation ID is automatically added to the outgoing HTTP request headers. This means downstream services will receive the same correlation ID, enabling end-to-end tracing across microservices. |
|||
|
|||
``` |
|||
Client Request (X-Correlation-Id: abc123) |
|||
→ Service A (receives abc123, sets in context) |
|||
→ Service B via HTTP Client Proxy (forwards abc123 in header) |
|||
→ Service C via HTTP Client Proxy (forwards abc123 in header) |
|||
``` |
|||
|
|||
No manual configuration is required. ABP's `ClientProxyBase` automatically reads the current correlation ID from `ICorrelationIdProvider` and adds it as a request header. |
|||
|
|||
### Distributed Event Bus |
|||
|
|||
When you publish a [distributed event](./event-bus/distributed/index.md), ABP automatically attaches the current correlation ID to the outgoing event message. When the event is consumed (potentially by a different service), the correlation ID is extracted from the message and set in the consumer's context. |
|||
|
|||
> This works with all supported event bus providers, including [RabbitMQ](./event-bus/distributed/rabbitmq.md), [Kafka](./event-bus/distributed/kafka.md), [Azure Service Bus](./event-bus/distributed/azure.md) and [Rebus](./event-bus/distributed/rebus.md). |
|||
|
|||
``` |
|||
Service A publishes event (CorrelationId: abc123) |
|||
→ Event Bus (carries abc123 in message metadata) |
|||
→ Service B receives event (CorrelationId restored to abc123) |
|||
``` |
|||
|
|||
### Audit Logging |
|||
|
|||
ABP's [audit logging](./audit-logging.md) system automatically captures the current correlation ID when creating audit log entries. This is stored in the `CorrelationId` property of `AuditLogInfo`, allowing you to query and filter audit logs by correlation ID. |
|||
|
|||
This is particularly useful for: |
|||
|
|||
- Tracing all database changes made during a single request. |
|||
- Correlating audit log entries across multiple services for the same user action. |
|||
- Debugging and investigating issues by filtering logs with a specific correlation ID. |
|||
|
|||
### Security Logging |
|||
|
|||
Similar to audit logging, ABP's security log system also captures the current correlation ID. When security-related events are logged (such as login attempts, permission checks, etc.), the correlation ID is included in the `SecurityLogInfo.CorrelationId` property. |
|||
|
|||
### Serilog Integration |
|||
|
|||
If you use the **ABP Serilog integration**, the correlation ID is automatically added to the Serilog log context as a property. This means every log entry within a request will include the correlation ID, making it easy to filter and search logs. |
|||
|
|||
The correlation ID is enriched as a log property named `CorrelationId` by default. You can use it in your Serilog output template or structured log queries. |
|||
|
|||
## See Also |
|||
|
|||
- [Audit Logging](./audit-logging.md) |
|||
- [Distributed Event Bus](./event-bus/distributed/index.md) |
|||
- [Dynamic Client Proxies](../api-development/dynamic-csharp-clients.md) |
|||
|
|||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 98 KiB |
@ -0,0 +1,151 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Learn how Shared User Accounts work in ABP (UserSharingStrategy): login flow with tenant selection, switching tenants, inviting users, and the Pending Tenant registration flow." |
|||
} |
|||
``` |
|||
|
|||
# Shared User Accounts |
|||
|
|||
This document explains **Shared User Accounts**: a single user account can belong to multiple tenants, and the user can choose/switch the active tenant when signing in. |
|||
|
|||
> This is a **commercial** feature. It is mainly provided by Account.Pro and Identity.Pro (and related SaaS UI). |
|||
|
|||
## Introduction |
|||
|
|||
In a typical multi-tenant setup with the **Isolated** user strategy, a user belongs to exactly one tenant (or the Host), and uniqueness rules (username/email) are usually scoped per tenant. |
|||
|
|||
If you want a `one account, multiple tenants` experience (for example, inviting the same email address into multiple tenants), you should enable the **Shared** user strategy. |
|||
|
|||
## Enabling Shared User Accounts |
|||
|
|||
Enable shared accounts by configuring `AbpMultiTenancyOptions.UserSharingStrategy`: |
|||
|
|||
```csharp |
|||
Configure<AbpMultiTenancyOptions>(options => |
|||
{ |
|||
options.IsEnabled = true; |
|||
options.UserSharingStrategy = TenantUserSharingStrategy.Shared; |
|||
}); |
|||
``` |
|||
|
|||
### Constraints and Behavior |
|||
|
|||
When you use Shared User Accounts: |
|||
|
|||
- Username/email uniqueness becomes **global** (Host + all tenants). A username/email can exist only once, but that user can be invited into multiple tenants. |
|||
- Some security/user management settings (2FA, lockout, password policies, recaptcha, etc.) are managed at the **Host** level. |
|||
|
|||
If you are migrating from an isolated strategy, ABP will validate the existing data when you switch to Shared. If there are conflicts (e.g., the same email registered as separate users in different tenants), you must resolve them before enabling the shared strategy. See the [Migration Guide](#migration-guide) section below. |
|||
|
|||
## Tenant Selection During Login |
|||
|
|||
If a user account belongs to multiple tenants, the login flow prompts the user to select the tenant to sign in to: |
|||
|
|||
 |
|||
|
|||
## Switching Tenants |
|||
|
|||
After signing in, the user can switch between the tenants they have joined using the tenant switcher in the user menu: |
|||
|
|||
 |
|||
|
|||
## Leaving a Tenant |
|||
|
|||
Users can leave a tenant. After leaving, the user is no longer a member of that tenant, and the tenant can invite the user again later. |
|||
|
|||
> When a user leaves and later re-joins the same tenant, the `UserId` does not change and tenant-related data (roles, permissions, etc.) is preserved. |
|||
|
|||
## Inviting Users to a Tenant |
|||
|
|||
Tenant administrators can invite existing or not-yet-registered users to join a tenant. The invited user receives an email; clicking the link completes the join process. If the user doesn't have an account yet, they can register and join through the same flow. |
|||
|
|||
While inviting, you can also assign roles so the user gets the relevant permissions automatically after joining. |
|||
|
|||
> The invitation feature is also available in the Isolated strategy, but invited users can join only a single tenant. |
|||
|
|||
 |
|||
|
|||
## Managing Invitations |
|||
|
|||
From the invitation modal, you can view and manage sent invitations, including resending an invitation email and revoking individual or all invitations. |
|||
|
|||
 |
|||
|
|||
## Accepting an Invitation |
|||
|
|||
If the invited person already has an account, clicking the email link shows a confirmation screen to join the tenant: |
|||
|
|||
 |
|||
|
|||
If the invited person doesn't have an account yet, clicking the email link takes them to registration and then joins them to the tenant: |
|||
|
|||
 |
|||
|
|||
After accepting the invitation, the user can sign in and switch to that tenant. |
|||
|
|||
 |
|||
|
|||
## Inviting an Admin After Tenant Creation |
|||
|
|||
With Shared User Accounts, you typically don't create an `admin` user during tenant creation. Instead, create the tenant first, then invite an existing user (or a new user) and grant the required roles. |
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
> In the Isolated strategy, tenant creation commonly seeds an `admin` user automatically. With Shared User Accounts, you usually use invitations instead. |
|||
|
|||
### Registration Strategy for New Users |
|||
|
|||
When a user registers a new account, the user is not a member of any tenant by default (and is not a Host user). You can configure `AbpIdentityPendingTenantUserOptions.Strategy` to decide what happens next. |
|||
|
|||
Available strategies: |
|||
|
|||
- **CreateTenant**: Automatically creates a tenant for the new user and adds the user to that tenant. |
|||
- **Redirect**: Redirects the user to a URL where you can implement custom logic (commonly: a tenant selection/join experience). |
|||
- **Inform** (default): Shows an informational message telling the user to contact an administrator to join a tenant. |
|||
|
|||
> In this state, the user can't proceed into a tenant context until they follow the configured strategy. |
|||
|
|||
### CreateTenant Strategy |
|||
|
|||
```csharp |
|||
Configure<AbpIdentityPendingTenantUserOptions>(options => |
|||
{ |
|||
options.Strategy = AbpIdentityPendingTenantUserStrategy.CreateTenant; |
|||
}); |
|||
``` |
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
### Redirect Strategy |
|||
|
|||
```csharp |
|||
Configure<AbpIdentityPendingTenantUserOptions>(options => |
|||
{ |
|||
options.Strategy = AbpIdentityPendingTenantUserStrategy.Redirect; |
|||
options.RedirectUrl = "/your-custom-logic-url"; |
|||
}); |
|||
``` |
|||
|
|||
### Inform Strategy |
|||
|
|||
```csharp |
|||
Configure<AbpIdentityPendingTenantUserOptions>(options => |
|||
{ |
|||
options.Strategy = AbpIdentityPendingTenantUserStrategy.Inform; |
|||
}); |
|||
``` |
|||
|
|||
 |
|||
|
|||
## Migration Guide |
|||
|
|||
If you plan to migrate an existing multi-tenant application from an isolated strategy to Shared User Accounts, keep the following in mind: |
|||
|
|||
1. **Uniqueness check**: Before enabling Shared, ensure all existing usernames and emails are unique globally. ABP performs this check when you switch the strategy and reports conflicts. |
|||
2. **Tenants with separate databases**: If some tenants use separate databases, you must ensure the Host database contains matching user records in the `AbpUsers` table (and, if you use social login / passkeys, also sync `AbpUserLogins` and `AbpUserPasskeys`) so the Host-side records match the tenant-side data. After that, the framework can create/manage the user-to-tenant associations. |
|||
|
|||
@ -1,6 +1,10 @@ |
|||
namespace Volo.Abp.AspNetCore.Mvc.MultiTenancy; |
|||
using Volo.Abp.MultiTenancy; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.MultiTenancy; |
|||
|
|||
public class MultiTenancyInfoDto |
|||
{ |
|||
public bool IsEnabled { get; set; } |
|||
|
|||
public TenantUserSharingStrategy UserSharingStrategy { get; set; } |
|||
} |
|||
|
|||
@ -0,0 +1,270 @@ |
|||
using System; |
|||
|
|||
namespace Volo.Abp.Data; |
|||
|
|||
public static class DataFilterExtensions |
|||
{ |
|||
private sealed class CompositeDisposable : IDisposable |
|||
{ |
|||
private readonly IDisposable[] _disposables; |
|||
private bool _disposed; |
|||
|
|||
public CompositeDisposable(IDisposable[] disposables) |
|||
{ |
|||
_disposables = disposables; |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
if (_disposed) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
_disposed = true; |
|||
|
|||
foreach (var disposable in _disposables) |
|||
{ |
|||
disposable?.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4, T5>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>(), |
|||
filter.Disable<T5>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4, T5, T6>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>(), |
|||
filter.Disable<T5>(), |
|||
filter.Disable<T6>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4, T5, T6, T7>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
where T7 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>(), |
|||
filter.Disable<T5>(), |
|||
filter.Disable<T6>(), |
|||
filter.Disable<T7>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4, T5, T6, T7, T8>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
where T7 : class |
|||
where T8 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>(), |
|||
filter.Disable<T5>(), |
|||
filter.Disable<T6>(), |
|||
filter.Disable<T7>(), |
|||
filter.Disable<T8>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4, T5>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>(), |
|||
filter.Enable<T5>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4, T5, T6>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>(), |
|||
filter.Enable<T5>(), |
|||
filter.Enable<T6>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4, T5, T6, T7>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
where T7 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>(), |
|||
filter.Enable<T5>(), |
|||
filter.Enable<T6>(), |
|||
filter.Enable<T7>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4, T5, T6, T7, T8>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
where T7 : class |
|||
where T8 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>(), |
|||
filter.Enable<T5>(), |
|||
filter.Enable<T6>(), |
|||
filter.Enable<T7>(), |
|||
filter.Enable<T8>() |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
namespace Volo.Abp.EntityFrameworkCore.GlobalFilters; |
|||
|
|||
public interface IAbpEfCoreCompiledQueryCacheKeyProvider |
|||
{ |
|||
string? GetCompiledQueryCacheKey(); |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
namespace Volo.Abp.Http.Modeling; |
|||
|
|||
public class AuthorizeDataApiDescriptionModel |
|||
{ |
|||
public string? Policy { get; set; } |
|||
|
|||
public string? Roles { get; set; } |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
using System; |
|||
using Volo.Abp.Domain.Entities.Events.Distributed; |
|||
using Volo.Abp.EventBus; |
|||
|
|||
namespace Volo.Abp.MultiTenancy; |
|||
|
|||
[Serializable] |
|||
[EventName("abp.multi_tenancy.create.tenant")] |
|||
public class CreateTenantEto : EtoBase |
|||
{ |
|||
public string Name { get; set; } = default!; |
|||
|
|||
public string AdminEmailAddress { get; set; } = default!; |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
namespace Volo.Abp.MultiTenancy; |
|||
|
|||
public enum TenantUserSharingStrategy |
|||
{ |
|||
Isolated = 0, |
|||
|
|||
Shared = 1 |
|||
} |
|||
@ -0,0 +1,87 @@ |
|||
using Shouldly; |
|||
using System.Collections.Generic; |
|||
using System.Reflection; |
|||
using Volo.Abp.Cli.ProjectBuilding.Files; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding.Building.Steps; |
|||
|
|||
public class SolutionRenamer_Tests |
|||
{ |
|||
[Theory] |
|||
[InlineData("Demo", "demo")] |
|||
[InlineData("MyCompany", "myCompany")] |
|||
[InlineData("Acme", "acme")] |
|||
[InlineData("ABC", "aBC")] |
|||
public void ToCamelCaseWithNamespace_Should_Handle_Single_Segment_Names(string input, string expected) |
|||
{ |
|||
// Act
|
|||
var result = InvokeToCamelCaseWithNamespace(input); |
|||
|
|||
// Assert
|
|||
result.ShouldBe(expected); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("Demo.App", "demo.app")] |
|||
[InlineData("MyCompany.MyProject", "myCompany.myProject")] |
|||
[InlineData("Acme.Bookstore", "acme.bookstore")] |
|||
[InlineData("ABC.XYZ", "aBC.xYZ")] |
|||
public void ToCamelCaseWithNamespace_Should_Handle_Two_Segment_Names(string input, string expected) |
|||
{ |
|||
// Act
|
|||
var result = InvokeToCamelCaseWithNamespace(input); |
|||
|
|||
// Assert
|
|||
result.ShouldBe(expected); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("Demo.App.QoL", "demo.app.qoL")] |
|||
[InlineData("MyCompany.MyProject.Module", "myCompany.myProject.module")] |
|||
[InlineData("Acme.Bookstore.Application", "acme.bookstore.application")] |
|||
[InlineData("A.B.C.D", "a.b.c.d")] |
|||
public void ToCamelCaseWithNamespace_Should_Handle_Multi_Segment_Names(string input, string expected) |
|||
{ |
|||
// Act
|
|||
var result = InvokeToCamelCaseWithNamespace(input); |
|||
|
|||
// Assert
|
|||
result.ShouldBe(expected); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("", "")] |
|||
[InlineData("A", "a")] |
|||
[InlineData(".", ".")] |
|||
[InlineData("...", "...")] |
|||
public void ToCamelCaseWithNamespace_Should_Handle_Edge_Cases(string input, string expected) |
|||
{ |
|||
// Act
|
|||
var result = InvokeToCamelCaseWithNamespace(input); |
|||
|
|||
// Assert
|
|||
result.ShouldBe(expected); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ToCamelCaseWithNamespace_Should_Throw_On_Null_Input() |
|||
{ |
|||
// Act & Assert
|
|||
var exception = Should.Throw<System.Reflection.TargetInvocationException>(() => InvokeToCamelCaseWithNamespace(null)); |
|||
exception.InnerException.ShouldBeOfType<System.NullReferenceException>(); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Helper method to invoke the private static ToCamelCaseWithNamespace method using reflection
|
|||
/// </summary>
|
|||
private static string InvokeToCamelCaseWithNamespace(string input) |
|||
{ |
|||
var type = typeof(SolutionRenamer); |
|||
var method = type.GetMethod("ToCamelCaseWithNamespace", BindingFlags.NonPublic | BindingFlags.Static); |
|||
|
|||
method.ShouldNotBeNull("ToCamelCaseWithNamespace method should exist"); |
|||
|
|||
return (string)method.Invoke(null, new object[] { input }); |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
using System; |
|||
using Volo.Abp.MultiTenancy; |
|||
|
|||
namespace Volo.Abp.Identity; |
|||
|
|||
[Serializable] |
|||
public class IdentityUserPasswordChangedEto : IMultiTenant |
|||
{ |
|||
public Guid Id { get; set; } |
|||
|
|||
public Guid? TenantId { get; set; } |
|||
|
|||
public string Email { get; set; } |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
using System; |
|||
using Volo.Abp.EventBus; |
|||
using Volo.Abp.MultiTenancy; |
|||
|
|||
namespace Volo.Abp.Users; |
|||
|
|||
[Serializable] |
|||
[EventName("Volo.Abp.Users.InviteUserToTenantRequested")] |
|||
public class InviteUserToTenantRequestedEto : IMultiTenant |
|||
{ |
|||
public Guid? TenantId { get; set; } |
|||
|
|||
public string Email { get; set; } |
|||
|
|||
public bool DirectlyAddToTenant { get; set; } |
|||
} |
|||
@ -0,0 +1,659 @@ |
|||
# ABP Npm Package Development Guide |
|||
|
|||
## Table of Contents |
|||
|
|||
1. [Overview](#overview) |
|||
2. [Project Structure](#project-structure) |
|||
3. [Module Architecture](#module-architecture) |
|||
4. [Component Development](#component-development) |
|||
5. [Service Layer](#service-layer) |
|||
6. [Routing & Navigation](#routing--navigation) |
|||
7. [State Management](#state-management) |
|||
8. [Form Handling](#form-handling) |
|||
9. [Validation](#validation) |
|||
10. [Testing](#testing) |
|||
11. [Best Practices](#best-practices) |
|||
12. [Common Patterns](#common-patterns) |
|||
13. [Troubleshooting](#troubleshooting) |
|||
|
|||
## Overview |
|||
|
|||
This guide provides comprehensive instructions for developing ABP modules following the established patterns and conventions. The guide is based on the SaaS module structure and can be applied to any ABP module development. |
|||
|
|||
### Key Principles |
|||
|
|||
- **Modularity**: Each module should be self-contained with clear boundaries |
|||
- **Extensibility**: Modules should support customization through extension points |
|||
- **Consistency**: Follow established ABP patterns and conventions |
|||
- **Testability**: All components should be easily testable |
|||
- **Performance**: Optimize for bundle size and runtime performance |
|||
|
|||
## Project Structure |
|||
|
|||
``` |
|||
package-name/ |
|||
├── package.json # Package metadata and dependencies |
|||
├── ng-package.json # ng-packagr configuration |
|||
├── project.json # Nx workspace configuration |
|||
├── tsconfig.json # TypeScript root config |
|||
├── tsconfig.lib.json # Library-specific TS config |
|||
├── tsconfig.lib.prod.json # Production build config |
|||
├── tsconfig.spec.json # Test configuration |
|||
├── jest.config.ts # Jest test configuration |
|||
├── tslint.json # (optional) Linting rules |
|||
├── README.md # Package documentation |
|||
│ |
|||
├── src/ # Main library source code |
|||
│ ├── lib/ # Core library implementation |
|||
│ │ ├── components/ # Angular components |
|||
│ │ ├── services/ # Business logic services |
|||
│ │ ├── models/ # TypeScript interfaces/models |
|||
│ │ ├── enums/ # Enumerations |
|||
│ │ ├── guards/ # Route guards |
|||
│ │ ├── resolvers/ # Route resolvers |
|||
│ │ ├── defaults/ # Default configurations |
|||
│ │ ├── tokens/ # Dependency injection tokens |
|||
│ │ ├── utils/ # Utility functions |
|||
│ │ ├── validators/ # Form validators |
|||
│ │ ├── [feature].routes.ts |
|||
│ └── public-api.ts # Public exports barrel file |
|||
│ |
|||
├── config/ # Configuration sub-package (optional) |
|||
│ ├── ng-package.json |
|||
│ └── src/ |
|||
│ ├── components/ # Config-specific components |
|||
│ ├── providers/ # Route/setting providers |
|||
│ ├── services/ # Config services |
|||
│ ├── models/ # Config models |
|||
│ ├── enums/ # Config enums |
|||
│ └── public-api.ts |
|||
│ |
|||
├── proxy/ # API proxy sub-package (optional) |
|||
│ ├── ng-package.json |
|||
│ └── src/ |
|||
│ ├── lib/ |
|||
│ │ └── proxy/ |
|||
│ │ ├── [feature]/ # Generated proxy services |
|||
│ │ ├── generate-proxy.json |
|||
│ │ └── README.md |
|||
│ └── public-api.ts |
|||
│ |
|||
├── common/ # Common/shared sub-package (optional) |
|||
│ ├── ng-package.json |
|||
│ └── src/ |
|||
│ ├── enums/ |
|||
│ ├── tokens/ |
|||
│ └── public-api.ts |
|||
│ |
|||
└── admin/ # Admin-specific sub-package (optional) |
|||
├── ng-package.json |
|||
└── src/ |
|||
└── ... |
|||
``` |
|||
|
|||
### File Naming Conventions |
|||
|
|||
- Use kebab-case for file names: `my-component.component.ts` |
|||
- Use PascalCase for class names: `MyComponent` |
|||
- Use camelCase for variables and methods: `myVariable`, `myMethod()` |
|||
- Use UPPER_SNAKE_CASE for constants: `MY_CONSTANT` |
|||
|
|||
## Package Architecture |
|||
|
|||
### Configuration Options Pattern |
|||
|
|||
```typescript |
|||
export interface MyConfigOptions { |
|||
entityActionContributors?: MyEntityActionContributors; |
|||
toolbarActionContributors?: MyToolbarActionContributors; |
|||
entityPropContributors?: MyEntityPropContributors; |
|||
createFormPropContributors?: MyCreateFormPropContributors; |
|||
editFormPropContributors?: MyEditFormPropContributors; |
|||
} |
|||
``` |
|||
|
|||
## Component Development |
|||
|
|||
### Component Structure |
|||
|
|||
```typescript |
|||
import { Component, OnInit, OnDestroy } from '@angular/core'; |
|||
import { ListService } from '@abp/ng.core'; |
|||
import { MyService } from '../services'; |
|||
|
|||
@Component({ |
|||
selector: 'app-my-component', |
|||
templateUrl: './my-component.component.html', |
|||
providers: [ |
|||
ListService, |
|||
{ |
|||
provide: EXTENSIONS_IDENTIFIER, |
|||
useValue: eMyComponents.MyComponent, |
|||
}, |
|||
], |
|||
imports: [], |
|||
}) |
|||
export class MyComponent implements OnInit, OnDestroy { |
|||
// Properties |
|||
data = this.list.getGrid(); |
|||
isModalVisible = false; |
|||
|
|||
public readonly list = inject(ListService); |
|||
private myService = inject(MyService); |
|||
|
|||
ngOnInit() { |
|||
this.hookToQuery(); |
|||
} |
|||
|
|||
ngOnDestroy() { |
|||
this.list.hookToQuery = () => {}; |
|||
} |
|||
|
|||
// Methods |
|||
onEdit(id: string) { |
|||
// Implementation |
|||
} |
|||
|
|||
onDelete(id: string) { |
|||
// Implementation |
|||
} |
|||
|
|||
private hookToQuery() { |
|||
this.list |
|||
.hookToQuery(query => this.myService.getList({ ...query, ...this.filters })) |
|||
.subscribe(res => (this.data = res)); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Example HTML template |
|||
|
|||
```html |
|||
<abp-page [title]="'AbpLocalizationKey::SubKey' | abpLocalization" [toolbar]="data.items"> |
|||
<div> |
|||
<div class="mt-2 mt-sm-0"> |
|||
<abp-advanced-entity-filters [list]="list" localizationSourceName="AbpLocalizationKey"> |
|||
<abp-advanced-entity-filters-form> |
|||
<form #filterForm (keyup.enter)="list.get()"> |
|||
<!-- ... --> |
|||
</form> |
|||
</abp-advanced-entity-filters-form> |
|||
</abp-advanced-entity-filters> |
|||
</div> |
|||
<div class="card"> |
|||
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" /> |
|||
</div> |
|||
</div> |
|||
</abp-page> |
|||
|
|||
<abp-modal [(visible)]="isModalVisible" [busy]="modalBusy" (disappear)="form = null"> |
|||
<ng-template #abpHeader> |
|||
<h3> |
|||
@if (selected?.id) { {{ 'AbpLocalizationKey::Edit' | abpLocalization }} @if |
|||
(selected.userName) { - {{ selected.userName }} } } @else { {{ 'AbpLocalizationKey::New' | |
|||
abpLocalization }} } |
|||
</h3> |
|||
</ng-template> |
|||
|
|||
<ng-template #abpBody> |
|||
@if (form) { |
|||
<form [formGroup]="form" id="myForm" (ngSubmit)="save()" validateOnSubmit> |
|||
<a ngbNavLink>{{ 'AbpLocalizationKey::MyInfo' | abpLocalization }}</a> |
|||
<ng-template ngbNavContent> |
|||
<div class="row"> |
|||
<abp-extensible-form class="row gap-x2" [selectedRecord]="selected" /> |
|||
</div> |
|||
</ng-template> |
|||
</form> |
|||
} |
|||
</ng-template> |
|||
|
|||
<ng-template #abpFooter> |
|||
<button type="button" class="btn btn-outline-primary" abpClose> |
|||
{{ 'AbpUi::Cancel' | abpLocalization }} |
|||
</button> |
|||
<abp-button iconClass="fa fa-check" buttonType="submit" formName="myForm"> |
|||
{{ 'AbpUi::Save' | abpLocalization }} |
|||
</abp-button> |
|||
</ng-template> |
|||
</abp-modal> |
|||
``` |
|||
|
|||
### Component Best Practices |
|||
|
|||
1. **Single Responsibility**: Each component should have one clear purpose |
|||
2. **Dependency Injection**: Use constructor injection for services |
|||
3. **Lifecycle Management**: Implement `OnInit` and `OnDestroy` when needed |
|||
4. **State Management**: Use reactive forms and observables |
|||
5. **Error Handling**: Implement proper error boundaries |
|||
6. **Accessibility**: Follow ARIA guidelines |
|||
7. **Performance**: Use `OnPush` change detection when possible |
|||
|
|||
## Service Layer |
|||
|
|||
### Service Structure |
|||
|
|||
```typescript |
|||
import { Injectable } from '@angular/core'; |
|||
import { RestService } from '@abp/ng.core'; |
|||
import { MyDto } from '../models'; |
|||
|
|||
@Injectable({ |
|||
providedIn: 'root', |
|||
}) |
|||
export class MyService extends RestService { |
|||
protected get url() { |
|||
return 'api/my-endpoint'; |
|||
} |
|||
|
|||
getList(query: any) { |
|||
return this.request<MyDto[]>({ |
|||
method: 'GET', |
|||
params: query, |
|||
}); |
|||
} |
|||
|
|||
getById(id: string) { |
|||
return this.request<MyDto>({ |
|||
method: 'GET', |
|||
url: `${this.url}/${id}`, |
|||
}); |
|||
} |
|||
|
|||
create(input: Partial<MyDto>) { |
|||
return this.request<MyDto>({ |
|||
method: 'POST', |
|||
body: input, |
|||
}); |
|||
} |
|||
|
|||
update(id: string, input: Partial<MyDto>) { |
|||
return this.request<MyDto>({ |
|||
method: 'PUT', |
|||
url: `${this.url}/${id}`, |
|||
body: input, |
|||
}); |
|||
} |
|||
|
|||
delete(id: string) { |
|||
return this.request<void>({ |
|||
method: 'DELETE', |
|||
url: `${this.url}/${id}`, |
|||
}); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Service Best Practices |
|||
|
|||
1. **Extend RestService**: Use ABP's base service for HTTP operations |
|||
2. **Type Safety**: Use TypeScript interfaces for all data structures |
|||
3. **Error Handling**: Implement proper error handling and logging |
|||
4. **Caching**: Consider caching strategies for frequently accessed data |
|||
5. **Observables**: Use RxJS observables for reactive programming |
|||
|
|||
## Routing & Navigation |
|||
|
|||
### Route Configuration |
|||
|
|||
```typescript |
|||
import { Routes } from '@angular/router'; |
|||
import { Provider } from '@angular/core'; |
|||
import { |
|||
RouterOutletComponent, |
|||
authGuard, |
|||
permissionGuard, |
|||
ReplaceableRouteContainerComponent, |
|||
ReplaceableComponents, |
|||
} from '@abp/ng.core'; |
|||
|
|||
export function createRoutes(config: MyConfigOptions = {}): Routes { |
|||
return [ |
|||
{ path: '', redirectTo: 'my-feature', pathMatch: 'full' }, |
|||
{ |
|||
path: '', |
|||
component: RouterOutletComponent, |
|||
providers: provideMyContributors(config), |
|||
canActivate: [authGuard, permissionGuard], |
|||
children: [ |
|||
{ |
|||
path: 'my-feature', |
|||
component: ReplaceableRouteContainerComponent, |
|||
data: { |
|||
requiredPolicy: 'My.Feature', |
|||
replaceableComponent: { |
|||
key: eMyComponents.MyFeature, |
|||
defaultComponent: MyFeatureComponent, |
|||
} as ReplaceableComponents.RouteData<MyFeatureComponent>, |
|||
}, |
|||
title: 'My::Feature', |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
} |
|||
|
|||
function provideMyContributors(options: MyConfigOptions = {}): Provider[] { |
|||
return [ |
|||
// ... providers |
|||
]; |
|||
} |
|||
``` |
|||
|
|||
### Route Best Practices |
|||
|
|||
1. **Lazy Loading**: Use lazy loading for better performance |
|||
2. **Guards**: Implement authentication and authorization guards |
|||
3. **Permissions**: Use permission-based route protection |
|||
4. **Replaceable Components**: Support component replacement for extensibility |
|||
5. **SEO**: Use meaningful route titles and metadata |
|||
|
|||
## State Management |
|||
|
|||
### State Management Patterns |
|||
|
|||
```typescript |
|||
// Using ABP's ConfigStateService |
|||
import { ConfigStateService } from '@abp/ng.core'; |
|||
|
|||
export class MyComponent { |
|||
private configState = inject(ConfigStateService); |
|||
|
|||
getSettings() { |
|||
return this.configState.getSetting('My.Setting'); |
|||
} |
|||
} |
|||
|
|||
// Using reactive forms |
|||
import { FormBuilder, FormGroup } from '@angular/forms'; |
|||
|
|||
export class MyComponent { |
|||
form: FormGroup; |
|||
private fb = inject(FormBuilder); |
|||
|
|||
constructor() { |
|||
this.form = this.fb.group({ |
|||
name: ['', Validators.required], |
|||
email: ['', [Validators.required, Validators.email]], |
|||
}); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## Form Handling |
|||
|
|||
### Form Structure |
|||
|
|||
```typescript |
|||
import { Component } from '@angular/core'; |
|||
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; |
|||
import { MyService } from '../services'; |
|||
|
|||
@Component({ |
|||
selector: 'app-my-form', |
|||
template: ` |
|||
<form [formGroup]="form" (ngSubmit)="onSubmit()"> |
|||
<input formControlName="name" /> |
|||
<input formControlName="email" /> |
|||
<button type="submit">Submit</button> |
|||
</form> |
|||
`, |
|||
imports: [], |
|||
}) |
|||
export class MyFormComponent { |
|||
form: FormGroup; |
|||
|
|||
private fb = inject(FormBuilder); |
|||
private myService = inject(MyService); |
|||
|
|||
constructor() { |
|||
this.form = this.fb.group({ |
|||
name: ['', Validators.required], |
|||
email: ['', [Validators.required, Validators.email]], |
|||
}); |
|||
} |
|||
|
|||
onSubmit() { |
|||
if (this.form.valid) { |
|||
this.myService.create(this.form.value).subscribe( |
|||
result => { |
|||
// Handle success |
|||
}, |
|||
error => { |
|||
// Handle error |
|||
}, |
|||
); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## Validation |
|||
|
|||
### Custom Validators |
|||
|
|||
```typescript |
|||
import { Provider } from '@angular/core'; |
|||
import { AsyncValidatorFn, FormGroup } from '@angular/forms'; |
|||
import { of } from 'rxjs'; |
|||
|
|||
export const MY_VALIDATOR_PROVIDER: Provider = { |
|||
provide: MY_FORM_ASYNC_VALIDATORS_TOKEN, |
|||
multi: true, |
|||
useFactory: myCustomValidator, |
|||
}; |
|||
|
|||
export function myCustomValidator(): AsyncValidatorFn { |
|||
return (group: FormGroup) => { |
|||
// Validation logic |
|||
const field1 = group?.get('field1'); |
|||
const field2 = group?.get('field2'); |
|||
|
|||
if (!field1 || !field2) { |
|||
return of(null); |
|||
} |
|||
|
|||
if (field1.value && !field2.value) { |
|||
field2.setErrors({ required: true }); |
|||
} |
|||
|
|||
return of(null); |
|||
}; |
|||
} |
|||
``` |
|||
|
|||
### Validation Best Practices |
|||
|
|||
1. **Async Validators**: Use for server-side validation |
|||
2. **Cross-field Validation**: Validate relationships between fields |
|||
3. **Error Messages**: Provide clear, user-friendly error messages |
|||
4. **Performance**: Debounce async validators to avoid excessive API calls |
|||
5. **Accessibility**: Ensure validation errors are announced to screen readers |
|||
|
|||
## Testing |
|||
|
|||
### Unit Testing Structure |
|||
|
|||
```typescript |
|||
import { ComponentFixture, TestBed } from '@angular/core/testing'; |
|||
import { MyComponent } from './my.component'; |
|||
import { MyService } from '../services'; |
|||
|
|||
describe('MyComponent', () => { |
|||
let component: MyComponent; |
|||
let fixture: ComponentFixture<MyComponent>; |
|||
let myService: jasmine.SpyObj<MyService>; |
|||
|
|||
beforeEach(async () => { |
|||
const spy = jasmine.createSpyObj('MyService', ['getList']); |
|||
|
|||
await TestBed.configureTestingModule({ |
|||
declarations: [MyComponent], |
|||
providers: [{ provide: MyService, useValue: spy }], |
|||
}).compileComponents(); |
|||
|
|||
fixture = TestBed.createComponent(MyComponent); |
|||
component = fixture.componentInstance; |
|||
myService = TestBed.inject(MyService) as jasmine.SpyObj<MyService>; |
|||
}); |
|||
|
|||
it('should create', () => { |
|||
expect(component).toBeTruthy(); |
|||
}); |
|||
|
|||
it('should load data on init', () => { |
|||
// Test implementation |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
### Testing Best Practices |
|||
|
|||
1. **Isolation**: Test components in isolation |
|||
2. **Mocking**: Mock external dependencies |
|||
3. **Coverage**: Aim for high test coverage |
|||
4. **Integration Tests**: Test component interactions |
|||
5. **E2E Tests**: Test complete user workflows |
|||
|
|||
## Best Practices |
|||
|
|||
### Code Organization |
|||
|
|||
1. **Feature-based Structure**: Organize code by features, not types |
|||
2. **Barrel Exports**: Use index files for clean imports |
|||
3. **Consistent Naming**: Follow established naming conventions |
|||
4. **Documentation**: Document complex logic and public APIs |
|||
5. **Type Safety**: Use TypeScript strictly |
|||
|
|||
### Performance |
|||
|
|||
1. **Lazy Loading**: Load modules on demand |
|||
2. **Change Detection**: Use OnPush strategy when possible |
|||
3. **Memory Management**: Unsubscribe from observables |
|||
4. **Bundle Size**: Minimize bundle size through tree shaking |
|||
5. **Caching**: Implement appropriate caching strategies |
|||
|
|||
### Security |
|||
|
|||
1. **Input Validation**: Validate all user inputs |
|||
2. **XSS Prevention**: Sanitize user-generated content |
|||
3. **CSRF Protection**: Use CSRF tokens for state-changing operations |
|||
4. **Authorization**: Check permissions at component and service levels |
|||
5. **HTTPS**: Use HTTPS in production |
|||
|
|||
## Common Patterns |
|||
|
|||
### Extension Pattern |
|||
|
|||
```typescript |
|||
// Define extension tokens |
|||
export const MY_ENTITY_ACTION_CONTRIBUTORS = new InjectionToken<EntityActionContributors>( |
|||
'MY_ENTITY_ACTION_CONTRIBUTORS' |
|||
); |
|||
|
|||
// Provide default implementations |
|||
export const DEFAULT_MY_ENTITY_ACTIONS = { |
|||
[eMyComponents.MyFeature]: DEFAULT_MY_FEATURE_ACTIONS, |
|||
}; |
|||
|
|||
// Use in module |
|||
{ |
|||
provide: MY_ENTITY_ACTION_CONTRIBUTORS, |
|||
useValue: options.entityActionContributors || DEFAULT_MY_ENTITY_ACTIONS, |
|||
} |
|||
``` |
|||
|
|||
### Modal Pattern |
|||
|
|||
```typescript |
|||
@Injectable({ |
|||
providedIn: 'root', |
|||
}) |
|||
export class MyModalService { |
|||
private modalRef: NgbModalRef; |
|||
|
|||
private modalService = inject(NgbModal); |
|||
|
|||
show(data?: any): NgbModalRef { |
|||
this.modalRef = this.modalService.open(MyModalComponent, { |
|||
size: 'lg', |
|||
backdrop: 'static', |
|||
}); |
|||
|
|||
if (data) { |
|||
this.modalRef.componentInstance.data = data; |
|||
} |
|||
|
|||
return this.modalRef; |
|||
} |
|||
|
|||
close() { |
|||
if (this.modalRef) { |
|||
this.modalRef.close(); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### List Pattern |
|||
|
|||
```typescript |
|||
export class MyListComponent { |
|||
data = this.list.getGrid(); |
|||
|
|||
readonly list = inject(ListService); |
|||
|
|||
ngOnInit() { |
|||
this.hookToQuery(); |
|||
} |
|||
|
|||
private hookToQuery() { |
|||
this.list |
|||
.hookToQuery(query => this.pageService.getList({ ...query, ...this.filters })) |
|||
.subscribe(res => (this.data = res)); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## Troubleshooting |
|||
|
|||
### Common Issues |
|||
|
|||
1. **Module Not Found**: Check import paths and module declarations |
|||
2. **Circular Dependencies**: Use forwardRef() or restructure imports |
|||
3. **Memory Leaks**: Ensure proper cleanup in ngOnDestroy |
|||
4. **Performance Issues**: Use OnPush change detection and memoization |
|||
5. **Type Errors**: Ensure proper TypeScript configuration |
|||
|
|||
### Debugging Tips |
|||
|
|||
1. **Angular DevTools**: Use Angular DevTools for component inspection |
|||
2. **Console Logging**: Use console.log strategically for debugging |
|||
3. **Network Tab**: Monitor API calls in browser dev tools |
|||
4. **Error Boundaries**: Implement error boundaries for graceful error handling |
|||
5. **Unit Tests**: Use tests to reproduce and fix bugs |
|||
|
|||
### Performance Optimization |
|||
|
|||
1. **Bundle Analysis**: Use webpack-bundle-analyzer to identify large dependencies |
|||
2. **Lazy Loading**: Implement route-based code splitting |
|||
3. **Tree Shaking**: Ensure unused code is eliminated |
|||
4. **Caching**: Implement appropriate caching strategies |
|||
5. **Minification**: Ensure proper minification in production builds |
|||
|
|||
--- |
|||
|
|||
## Conclusion |
|||
|
|||
This guide provides a comprehensive overview of ABP module development patterns and best practices. Follow these guidelines to create maintainable, extensible, and performant modules that integrate seamlessly with the ABP framework. |
|||
|
|||
Remember to: |
|||
|
|||
- Follow established ABP conventions |
|||
- Write comprehensive tests |
|||
- Document your code |
|||
- Consider performance implications |
|||
- Implement proper error handling |
|||
- Use TypeScript features effectively |
|||
|
|||
For more information, refer to the official ABP documentation and community resources. |
|||
@ -0,0 +1,452 @@ |
|||
# ABP Package Development - Quick Reference |
|||
|
|||
## Essential Commands |
|||
|
|||
### Build & Test |
|||
|
|||
```bash |
|||
# Open a terminal on ng-packs directory |
|||
cd /Users/sumeyyekurtulus/Desktop/volosoft/GITHUB/abp/npm/ng-packs |
|||
|
|||
# Build the package |
|||
yarn nx build package-name --skip-nx-cache |
|||
|
|||
# Run tests |
|||
yarn nx test package-name --test-file test-file.spec.ts |
|||
|
|||
# Lint code |
|||
yarn run lint |
|||
|
|||
# Build for production |
|||
yarn nx build package-name --configuration=production |
|||
``` |
|||
|
|||
### Development |
|||
|
|||
```bash |
|||
# Open a terminal on ng-packs directory |
|||
cd /Users/sumeyyekurtulus/Desktop/volosoft/GITHUB/abp/npm/ng-packs |
|||
|
|||
# Start development server |
|||
yarn start |
|||
|
|||
# Watch for changes |
|||
yarn run watch |
|||
|
|||
# Generate component |
|||
ng generate component my-component |
|||
|
|||
# Generate service |
|||
ng generate service my-service |
|||
``` |
|||
|
|||
## File Structure Quick Reference |
|||
|
|||
``` |
|||
package-name/ |
|||
├── package.json # Package metadata and dependencies |
|||
├── ng-package.json # ng-packagr configuration |
|||
├── project.json # Nx workspace configuration |
|||
├── tsconfig.json # TypeScript root config |
|||
├── tsconfig.lib.json # Library-specific TS config |
|||
├── tsconfig.lib.prod.json # Production build config |
|||
├── tsconfig.spec.json # Test configuration |
|||
├── jest.config.ts # Jest test configuration |
|||
├── tslint.json # (optional) Linting rules |
|||
├── README.md # Package documentation |
|||
│ |
|||
├── src/ # Main library source code |
|||
│ ├── lib/ # Core library implementation |
|||
│ │ ├── components/ # Angular components |
|||
│ │ ├── services/ # Business logic services |
|||
│ │ ├── models/ # TypeScript interfaces/models |
|||
│ │ ├── enums/ # Enumerations |
|||
│ │ ├── guards/ # Route guards |
|||
│ │ ├── resolvers/ # Route resolvers |
|||
│ │ ├── defaults/ # Default configurations |
|||
│ │ ├── tokens/ # Dependency injection tokens |
|||
│ │ ├── utils/ # Utility functions |
|||
│ │ ├── validators/ # Form validators |
|||
│ │ ├── [feature].routes.ts |
|||
│ └── public-api.ts # Public exports barrel file |
|||
│ |
|||
├── config/ # Configuration sub-package (optional) |
|||
│ ├── ng-package.json |
|||
│ └── src/ |
|||
│ ├── components/ # Config-specific components |
|||
│ ├── providers/ # Route/setting providers |
|||
│ ├── services/ # Config services |
|||
│ ├── models/ # Config models |
|||
│ ├── enums/ # Config enums |
|||
│ └── public-api.ts |
|||
│ |
|||
├── proxy/ # API proxy sub-package (optional) [Do not touch while making generation] |
|||
│ ├── ng-package.json |
|||
│ └── src/ |
|||
│ ├── lib/ |
|||
│ │ └── proxy/ |
|||
│ │ ├── [feature]/ # Generated proxy services |
|||
│ │ ├── generate-proxy.json |
|||
│ │ └── README.md |
|||
│ └── public-api.ts |
|||
│ |
|||
├── common/ # Common/shared sub-package (optional) |
|||
│ ├── ng-package.json |
|||
│ └── src/ |
|||
│ ├── enums/ |
|||
│ ├── tokens/ |
|||
│ └── public-api.ts |
|||
│ |
|||
└── admin/ # Admin-specific sub-package (optional) |
|||
├── ng-package.json |
|||
└── src/ |
|||
└── ... |
|||
``` |
|||
|
|||
## Common Patterns |
|||
|
|||
### Component Definition |
|||
|
|||
```typescript |
|||
@Component({ |
|||
selector: 'app-my-component', |
|||
templateUrl: './my-component.component.html', |
|||
providers: [ |
|||
ListService, |
|||
{ |
|||
provide: EXTENSIONS_IDENTIFIER, |
|||
useValue: eMyComponents.MyComponent, |
|||
}, |
|||
], |
|||
imports: [], |
|||
}) |
|||
export class MyComponent implements OnInit { |
|||
public readonly list = inject(ListService); |
|||
private myComponentService = inject(MyComponentService); |
|||
|
|||
data = this.list.getGrid(); |
|||
|
|||
ngOnInit() { |
|||
this.hookToQuery(); |
|||
} |
|||
|
|||
private hookToQuery() { |
|||
this.list |
|||
.hookToQuery(query => this.myComponentService.getList({ ...query, ...this.filters })) |
|||
.subscribe(res => (this.data = res)); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Default Extension Points |
|||
|
|||
```typescript |
|||
export const DEFAULT_MY_ENTITY_ACTIONS = EntityAction.createMany<MyElementDto>([ |
|||
{ |
|||
text: 'AbpPackage::MyElement', |
|||
action: data => { |
|||
const { piece } = data.record; |
|||
if (!piece) { |
|||
return; |
|||
} |
|||
|
|||
const router = data.getInjected(Router); |
|||
router.navigate(['/package/piece']); |
|||
}, |
|||
permission: 'AbpPackage.MyElement', |
|||
}, |
|||
]); |
|||
``` |
|||
|
|||
```typescript |
|||
export const DEFAULT_MY_ENTITY_PROPS = EntityProp.createMany<MyElementDto>([ |
|||
{ |
|||
type: ePropType.PropType, |
|||
name: 'propName', |
|||
displayName: 'AbpPackage::PropDisplayName', |
|||
sortable: true, |
|||
columnWidth: 200, |
|||
}, |
|||
]); |
|||
``` |
|||
|
|||
```typescript |
|||
export const DEFAULT_MY_CREATE_FORM_PROPS = FormProp.createMany<MyElementDto>([ |
|||
{ |
|||
type: ePropType.PropType, |
|||
name: 'propName', |
|||
displayName: 'AbpPackage::PropDisplayName', |
|||
id: 'propID', |
|||
}, |
|||
]); |
|||
export const DEFAULT_MY_EDIT_FORM_PROPS = DEFAULT_MY_CREATE_FORM_PROPS.filter( |
|||
prop => prop.name !== 'propName', |
|||
); |
|||
``` |
|||
|
|||
```typescript |
|||
export const DEFAULT_MY_TOOLBAR_COMPONENTS = ToolbarComponent.createMany<MyElementDto[]>([ |
|||
{ |
|||
permission: 'AbpPermissionKey', |
|||
component: MyComponent, |
|||
}, |
|||
]); |
|||
|
|||
export const DEFAULT_MY_TOOLBAR_ACTIONS = ToolbarAction.createMany<MyElementDto[]>([ |
|||
{ |
|||
text: 'AbpPackage::Text', |
|||
action: data => { |
|||
const component = data.getInjected(MyComponent); |
|||
component.onAdd(); |
|||
}, |
|||
permission: 'AbpPermissionKey', |
|||
icon: 'fa fa-plus', |
|||
}, |
|||
]); |
|||
|
|||
export const DEFAULT_USERS_TOOLBAR_ALL = [ |
|||
...DEFAULT_USERS_TOOLBAR_COMPONENTS, |
|||
...DEFAULT_USERS_TOOLBAR_ACTIONS, |
|||
]; |
|||
``` |
|||
|
|||
### Route Definition |
|||
|
|||
```typescript |
|||
export function createRoutes(config: MyConfigOptions = {}): Routes { |
|||
return [ |
|||
{ |
|||
path: '', |
|||
component: RouterOutletComponent, |
|||
providers: provideMyContributors(config), |
|||
canActivate: [authGuard, permissionGuard], |
|||
children: [ |
|||
{ |
|||
path: 'my-feature', |
|||
component: ReplaceableRouteContainerComponent, |
|||
data: { |
|||
requiredPolicy: 'My.Feature', |
|||
replaceableComponent: { |
|||
key: eMyComponents.MyFeature, |
|||
defaultComponent: MyFeatureComponent, |
|||
}, |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
} |
|||
|
|||
function provideMyContributors(options: MyConfigOptions = {}): Provider[] { |
|||
return [ |
|||
{ |
|||
provide: MY_ENTITY_ACTION_CONTRIBUTORS, |
|||
useValue: options.entityActionContributors, |
|||
}, |
|||
{ |
|||
provide: MY_TOOLBAR_ACTION_CONTRIBUTORS, |
|||
useValue: options.toolbarActionContributors, |
|||
}, |
|||
{ |
|||
provide: MY_ENTITY_PROP_CONTRIBUTORS, |
|||
useValue: options.entityPropContributors, |
|||
}, |
|||
{ |
|||
provide: MY_CREATE_FORM_PROP_CONTRIBUTORS, |
|||
useValue: options.createFormPropContributors, |
|||
}, |
|||
{ |
|||
provide: MY_EDIT_FORM_PROP_CONTRIBUTORS, |
|||
useValue: options.editFormPropContributors, |
|||
}, |
|||
]; |
|||
} |
|||
``` |
|||
|
|||
```typescript |
|||
export const APP_ROUTES: Routes = [ |
|||
{ |
|||
path: 'my-route', |
|||
loadChildren: () => import('my-package').then(c => c.createRoutes()), |
|||
}, |
|||
]; |
|||
``` |
|||
|
|||
### Provider Definitions |
|||
|
|||
```typescript |
|||
export const MY_ROUTE_PROVIDERS = [ |
|||
provideAppInitializer(() => { |
|||
configureRoutes(); |
|||
}), |
|||
]; |
|||
|
|||
export function configureRoutes() { |
|||
const routes = inject(RoutesService); |
|||
routes.add([ |
|||
{ |
|||
path: '/my-route', |
|||
name: eMyRouteNames.Route, |
|||
parentName: eThemeSharedRouteNames.Route, |
|||
order: 2, |
|||
layout: eLayoutType.application, |
|||
iconClass: 'fa fa-id-card-o', |
|||
requiredPolicy: eMyPolicyNames.Route, |
|||
}, |
|||
{ |
|||
path: '/my-route/my-sub-route', |
|||
name: eMyRouteNames.MySubRoute, |
|||
parentName: eMyRouteNames.Route, |
|||
order: 1, |
|||
requiredPolicy: eMyPolicyNames.MySubRoute, |
|||
}, |
|||
]); |
|||
} |
|||
``` |
|||
|
|||
```typescript |
|||
export function provideMyConfig() { |
|||
return makeEnvironmentProviders([MY_ROUTE_PROVIDERS]); |
|||
} |
|||
``` |
|||
|
|||
```typescript |
|||
export const appConfig: ApplicationConfig = { |
|||
providers: [provideMyConfig()], |
|||
}; |
|||
``` |
|||
|
|||
## Validation Patterns |
|||
|
|||
### Custom Validator |
|||
|
|||
```typescript |
|||
export const MY_VALIDATOR_PROVIDER: Provider = { |
|||
provide: MY_FORM_ASYNC_VALIDATORS_TOKEN, |
|||
multi: true, |
|||
useFactory: myCustomValidator, |
|||
}; |
|||
|
|||
export function myCustomValidator(): AsyncValidatorFn { |
|||
return (group: FormGroup) => { |
|||
// Validation logic |
|||
return of(null); |
|||
}; |
|||
} |
|||
``` |
|||
|
|||
## Testing Patterns |
|||
|
|||
### Component Test |
|||
|
|||
```typescript |
|||
describe('MyComponent', () => { |
|||
let component: MyComponent; |
|||
let fixture: ComponentFixture<MyComponent>; |
|||
|
|||
beforeEach(async () => { |
|||
await TestBed.configureTestingModule({ |
|||
declarations: [MyComponent], |
|||
providers: [{ provide: MyService, useValue: jasmine.createSpyObj('MyService', ['getList']) }], |
|||
}).compileComponents(); |
|||
|
|||
fixture = TestBed.createComponent(MyComponent); |
|||
component = fixture.componentInstance; |
|||
}); |
|||
|
|||
it('should create', () => { |
|||
expect(component).toBeTruthy(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
## Configuration Options |
|||
|
|||
### Module Configuration |
|||
|
|||
```typescript |
|||
export interface MyConfigOptions { |
|||
entityActionContributors?: MyEntityActionContributors; |
|||
toolbarActionContributors?: MyToolbarActionContributors; |
|||
entityPropContributors?: MyEntityPropContributors; |
|||
createFormPropContributors?: MyCreateFormPropContributors; |
|||
editFormPropContributors?: MyEditFormPropContributors; |
|||
} |
|||
``` |
|||
|
|||
## Common Imports |
|||
|
|||
### Common Services |
|||
|
|||
```typescript |
|||
import { ListService, ConfigStateService } from '@abp/ng.core'; |
|||
import { RestService } from '@abp/ng.core'; |
|||
import { EntityAction, FormProp } from '@abp/ng.components/extensible'; |
|||
``` |
|||
|
|||
### Common Guards & Components |
|||
|
|||
```typescript |
|||
import { authGuard, permissionGuard } from '@abp/ng.core'; |
|||
import { RouterOutletComponent, ReplaceableRouteContainerComponent } from '@abp/ng.core'; |
|||
``` |
|||
|
|||
## Naming Conventions |
|||
|
|||
| Type | Convention | Example | |
|||
| ----------------- | -------------------------- | --------------------------- | |
|||
| Files | kebab-case | `my-component.component.ts` | |
|||
| Classes | PascalCase | `MyComponent` | |
|||
| Variables/Methods | camelCase | `myVariable`, `myMethod()` | |
|||
| Constants | UPPER_SNAKE_CASE | `MY_CONSTANT` | |
|||
| Interfaces | PascalCase with 'I' prefix | `IMyInterface` | |
|||
| Enums | PascalCase with 'e' prefix | `eMyEnum` | |
|||
|
|||
## Best Practices Checklist |
|||
|
|||
- [ ] Follow single responsibility principle |
|||
- [ ] Use dependency injection |
|||
- [ ] Implement proper error handling |
|||
- [ ] Write unit tests |
|||
- [ ] Use TypeScript strictly |
|||
- [ ] Follow ABP conventions |
|||
- [ ] Document public APIs |
|||
- [ ] Optimize for performance |
|||
- [ ] Implement proper validation |
|||
- [ ] Use reactive forms |
|||
- [ ] Handle lifecycle properly |
|||
- [ ] Implement proper security |
|||
|
|||
## Common Issues & Solutions |
|||
|
|||
### Module Not Found |
|||
|
|||
- Check import paths |
|||
- Verify module declarations |
|||
- Ensure proper exports |
|||
|
|||
### Circular Dependencies |
|||
|
|||
- Use `forwardRef()` |
|||
- Restructure imports |
|||
- Move shared code to separate modules |
|||
|
|||
### Memory Leaks |
|||
|
|||
- Unsubscribe from observables |
|||
- Implement `OnDestroy` |
|||
- Use `takeUntil` operator |
|||
|
|||
### Performance Issues |
|||
|
|||
- Use `OnPush` change detection |
|||
- Implement lazy loading |
|||
- Optimize bundle size |
|||
|
|||
## Useful Resources |
|||
|
|||
- [ABP Documentation](https://docs.abp.io) |
|||
- [Angular Documentation](https://angular.io/docs) |
|||
- [TypeScript Handbook](https://www.typescriptlang.org/docs) |
|||
- [RxJS Documentation](https://rxjs.dev) |
|||
- [ABP Community](https://community.abp.io) |
|||
@ -0,0 +1,85 @@ |
|||
# @abp/ng.cms-kit |
|||
|
|||
ABP CMS Kit Angular package providing admin and public functionality for content management. |
|||
|
|||
## Structure |
|||
|
|||
This package is organized into two main sub-packages: |
|||
|
|||
- **Admin** (`admin/`) - Admin interface for managing CMS content |
|||
- **Public** (`public/`) - Public-facing components for displaying CMS content |
|||
|
|||
## Installation |
|||
|
|||
```bash |
|||
npm install @abp/ng.cms-kit |
|||
``` |
|||
|
|||
## Usage |
|||
|
|||
### Admin |
|||
|
|||
```typescript |
|||
import { provideCmsKitAdminConfig } from '@abp/ng.cms-kit/admin/config'; |
|||
|
|||
// In your app config |
|||
export const appConfig: ApplicationConfig = { |
|||
providers: [ |
|||
provideCmsKitAdminConfig(), |
|||
// ... other providers |
|||
], |
|||
}; |
|||
|
|||
// In your routes |
|||
export const routes: Routes = [ |
|||
{ |
|||
path: 'cms', |
|||
loadChildren: () => import('@abp/ng.cms-kit/admin').then(m => m.createRoutes()), |
|||
}, |
|||
]; |
|||
``` |
|||
|
|||
### Public |
|||
|
|||
```typescript |
|||
import { provideCmsKitPublicConfig } from '@abp/ng.cms-kit/public/config'; |
|||
|
|||
// In your app config |
|||
export const appConfig: ApplicationConfig = { |
|||
providers: [ |
|||
provideCmsKitPublicConfig(), |
|||
// ... other providers |
|||
], |
|||
}; |
|||
|
|||
// In your routes |
|||
export const routes: Routes = [ |
|||
{ |
|||
path: 'cms', |
|||
loadChildren: () => import('@abp/ng.cms-kit/public').then(m => m.createRoutes()), |
|||
}, |
|||
]; |
|||
``` |
|||
|
|||
## Features |
|||
|
|||
### Admin Features |
|||
|
|||
- Comments management |
|||
- Tags management |
|||
- Pages management |
|||
- Blogs management |
|||
- Blog posts management |
|||
- Menus management |
|||
- Global resources management |
|||
|
|||
### Public Features |
|||
|
|||
- Public page viewing |
|||
- Public blog and blog post viewing |
|||
- Commenting functionality |
|||
- Shared components (MarkedItemToggle, PopularTags, Rating, ReactionSelection, Tags) |
|||
|
|||
## Documentation |
|||
|
|||
For more information, see the [ABP Documentation](https://docs.abp.io). |
|||
@ -0,0 +1,6 @@ |
|||
{ |
|||
"$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", |
|||
"lib": { |
|||
"entryFile": "src/public-api.ts" |
|||
} |
|||
} |
|||
@ -0,0 +1,2 @@ |
|||
export * from './policy-names'; |
|||
export * from './route-names'; |
|||
@ -0,0 +1,10 @@ |
|||
export enum eCmsKitAdminPolicyNames { |
|||
Cms = 'CmsKit.Comments || CmsKit.Tags || CmsKit.Pages || CmsKit.Blogs || CmsKit.BlogPosts || CmsKit.Menus || CmsKit.GlobalResources', |
|||
Comments = 'CmsKit.Comments', |
|||
Tags = 'CmsKit.Tags', |
|||
Pages = 'CmsKit.Pages', |
|||
Blogs = 'CmsKit.Blogs', |
|||
BlogPosts = 'CmsKit.BlogPosts', |
|||
Menus = 'CmsKit.Menus', |
|||
GlobalResources = 'CmsKit.GlobalResources', |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
export enum eCmsKitAdminRouteNames { |
|||
Cms = 'CmsKit::Menu:CMS', |
|||
Comments = 'CmsKit::CmsKit.Comments', |
|||
Tags = 'CmsKit::CmsKit.Tags', |
|||
Pages = 'CmsKit::Pages', |
|||
Blogs = 'CmsKit::Blogs', |
|||
BlogPosts = 'CmsKit::BlogPosts', |
|||
Menus = 'CmsKit::Menus', |
|||
GlobalResources = 'CmsKit::GlobalResources', |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
export interface Settings { |
|||
commentRequireApprovement: boolean; |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './cms-kit-admin-settings'; |
|||
@ -0,0 +1,10 @@ |
|||
import { makeEnvironmentProviders } from '@angular/core'; |
|||
import { CMS_KIT_ADMIN_ROUTE_PROVIDERS } from './route.provider'; |
|||
import { CMS_KIT_ADMIN_SETTING_TAB_PROVIDERS } from './setting-tab.provider'; |
|||
|
|||
export function provideCmsKitAdminConfig() { |
|||
return makeEnvironmentProviders([ |
|||
CMS_KIT_ADMIN_ROUTE_PROVIDERS, |
|||
CMS_KIT_ADMIN_SETTING_TAB_PROVIDERS, |
|||
]); |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
export * from './cms-kit-admin-config.provider'; |
|||
export * from './route.provider'; |
|||
export * from './setting-tab.provider'; |
|||
@ -0,0 +1,79 @@ |
|||
import { eLayoutType, RoutesService } from '@abp/ng.core'; |
|||
import { inject, provideAppInitializer } from '@angular/core'; |
|||
import { eCmsKitAdminPolicyNames } from '../enums/policy-names'; |
|||
import { eCmsKitAdminRouteNames } from '../enums/route-names'; |
|||
|
|||
export const CMS_KIT_ADMIN_ROUTE_PROVIDERS = [ |
|||
provideAppInitializer(() => { |
|||
configureRoutes(); |
|||
}), |
|||
]; |
|||
|
|||
export function configureRoutes() { |
|||
const routesService = inject(RoutesService); |
|||
routesService.add([ |
|||
{ |
|||
path: '/cms/blog-posts', |
|||
name: eCmsKitAdminRouteNames.BlogPosts, |
|||
parentName: eCmsKitAdminRouteNames.Cms, |
|||
order: 1, |
|||
layout: eLayoutType.application, |
|||
iconClass: 'fa fa-file-alt', |
|||
requiredPolicy: eCmsKitAdminPolicyNames.BlogPosts, |
|||
}, |
|||
{ |
|||
path: '/cms/blogs', |
|||
name: eCmsKitAdminRouteNames.Blogs, |
|||
parentName: eCmsKitAdminRouteNames.Cms, |
|||
order: 2, |
|||
layout: eLayoutType.application, |
|||
iconClass: 'fa fa-blog', |
|||
requiredPolicy: eCmsKitAdminPolicyNames.Blogs, |
|||
}, |
|||
{ |
|||
path: '/cms/comments', |
|||
name: eCmsKitAdminRouteNames.Comments, |
|||
parentName: eCmsKitAdminRouteNames.Cms, |
|||
order: 3, |
|||
layout: eLayoutType.application, |
|||
iconClass: 'fa fa-comments', |
|||
requiredPolicy: eCmsKitAdminPolicyNames.Comments, |
|||
}, |
|||
{ |
|||
path: '/cms/global-resources', |
|||
name: eCmsKitAdminRouteNames.GlobalResources, |
|||
parentName: eCmsKitAdminRouteNames.Cms, |
|||
order: 5, |
|||
layout: eLayoutType.application, |
|||
iconClass: 'fa fa-globe', |
|||
requiredPolicy: eCmsKitAdminPolicyNames.GlobalResources, |
|||
}, |
|||
{ |
|||
path: '/cms/menus', |
|||
name: eCmsKitAdminRouteNames.Menus, |
|||
parentName: eCmsKitAdminRouteNames.Cms, |
|||
order: 6, |
|||
layout: eLayoutType.application, |
|||
iconClass: 'fa fa-bars', |
|||
requiredPolicy: eCmsKitAdminPolicyNames.Menus, |
|||
}, |
|||
{ |
|||
path: '/cms/pages', |
|||
name: eCmsKitAdminRouteNames.Pages, |
|||
parentName: eCmsKitAdminRouteNames.Cms, |
|||
order: 9, |
|||
layout: eLayoutType.application, |
|||
iconClass: 'fa fa-file', |
|||
requiredPolicy: eCmsKitAdminPolicyNames.Pages, |
|||
}, |
|||
{ |
|||
path: '/cms/tags', |
|||
name: eCmsKitAdminRouteNames.Tags, |
|||
parentName: eCmsKitAdminRouteNames.Cms, |
|||
order: 11, |
|||
layout: eLayoutType.application, |
|||
iconClass: 'fa fa-tags', |
|||
requiredPolicy: eCmsKitAdminPolicyNames.Tags, |
|||
}, |
|||
]); |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
import { ABP, ConfigStateService } from '@abp/ng.core'; |
|||
import { SettingTabsService } from '@abp/ng.setting-management/config'; |
|||
import { inject, provideAppInitializer } from '@angular/core'; |
|||
import { eCmsKitAdminPolicyNames, eCmsKitAdminRouteNames } from '../enums'; |
|||
import { CmsSettingsComponent } from '@abp/ng.cms-kit/admin'; |
|||
|
|||
export const CMS_KIT_ADMIN_SETTING_TAB_PROVIDERS = [ |
|||
provideAppInitializer(() => { |
|||
configureSettingTabs(); |
|||
}), |
|||
]; |
|||
|
|||
export async function configureSettingTabs() { |
|||
const settingTabs = inject(SettingTabsService); |
|||
const configState = inject(ConfigStateService); |
|||
const tabsArray: ABP.Tab[] = [ |
|||
{ |
|||
name: eCmsKitAdminRouteNames.Cms, |
|||
order: 100, |
|||
requiredPolicy: eCmsKitAdminPolicyNames.Cms, |
|||
invisible: configState.getFeature('CmsKit.CommentEnable')?.toLowerCase() === 'true', |
|||
component: CmsSettingsComponent, |
|||
}, |
|||
]; |
|||
|
|||
settingTabs.add(tabsArray); |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
export * from './enums'; |
|||
export * from './providers'; |
|||
export * from './models'; |
|||
@ -0,0 +1,6 @@ |
|||
{ |
|||
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", |
|||
"lib": { |
|||
"entryFile": "src/public-api.ts" |
|||
} |
|||
} |
|||
@ -0,0 +1,241 @@ |
|||
import { Routes } from '@angular/router'; |
|||
import { Provider } from '@angular/core'; |
|||
import { |
|||
RouterOutletComponent, |
|||
authGuard, |
|||
permissionGuard, |
|||
ReplaceableRouteContainerComponent, |
|||
} from '@abp/ng.core'; |
|||
import { eCmsKitAdminComponents } from './enums'; |
|||
import { |
|||
CommentListComponent, |
|||
CommentDetailsComponent, |
|||
TagListComponent, |
|||
PageListComponent, |
|||
PageFormComponent, |
|||
BlogListComponent, |
|||
BlogPostListComponent, |
|||
BlogPostFormComponent, |
|||
MenuItemListComponent, |
|||
GlobalResourcesComponent, |
|||
} from './components'; |
|||
import { |
|||
CMS_KIT_ADMIN_ENTITY_ACTION_CONTRIBUTORS, |
|||
CMS_KIT_ADMIN_ENTITY_PROP_CONTRIBUTORS, |
|||
CMS_KIT_ADMIN_TOOLBAR_ACTION_CONTRIBUTORS, |
|||
CMS_KIT_ADMIN_CREATE_FORM_PROP_CONTRIBUTORS, |
|||
CMS_KIT_ADMIN_EDIT_FORM_PROP_CONTRIBUTORS, |
|||
} from './tokens'; |
|||
import { cmsKitAdminExtensionsResolver } from './resolvers'; |
|||
import { CmsKitAdminConfigOptions } from './models'; |
|||
|
|||
export function createRoutes(config: CmsKitAdminConfigOptions = {}): Routes { |
|||
return [ |
|||
{ |
|||
path: '', |
|||
component: RouterOutletComponent, |
|||
providers: provideCmsKitAdminContributors(config), |
|||
canActivate: [authGuard, permissionGuard], |
|||
children: [ |
|||
{ |
|||
path: 'comments', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.Comments', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.CommentList, |
|||
defaultComponent: CommentListComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::Comments', |
|||
}, |
|||
{ |
|||
path: 'comments/:id', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.Comments', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.CommentDetails, |
|||
defaultComponent: CommentDetailsComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::Comments', |
|||
}, |
|||
{ |
|||
path: 'tags', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.Tags', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.Tags, |
|||
defaultComponent: TagListComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::Tags', |
|||
}, |
|||
{ |
|||
path: 'pages', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.Pages', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.Pages, |
|||
defaultComponent: PageListComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::Pages', |
|||
}, |
|||
{ |
|||
path: 'pages/create', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.Pages.Create', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.PageForm, |
|||
defaultComponent: PageFormComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::Pages', |
|||
}, |
|||
{ |
|||
path: 'pages/update/:id', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.Pages.Update', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.PageForm, |
|||
defaultComponent: PageFormComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::Pages', |
|||
}, |
|||
{ |
|||
path: 'blogs', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.Blogs', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.Blogs, |
|||
defaultComponent: BlogListComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::Blogs', |
|||
}, |
|||
{ |
|||
path: 'blog-posts', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.BlogPosts', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.BlogPosts, |
|||
defaultComponent: BlogPostListComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::BlogPosts', |
|||
}, |
|||
{ |
|||
path: 'blog-posts/create', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.BlogPosts.Create', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.BlogPostForm, |
|||
defaultComponent: BlogPostFormComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::BlogPosts', |
|||
}, |
|||
{ |
|||
path: 'blog-posts/update/:id', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.BlogPosts.Update', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.BlogPostForm, |
|||
defaultComponent: BlogPostFormComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::BlogPosts', |
|||
}, |
|||
{ |
|||
path: 'menus', |
|||
component: ReplaceableRouteContainerComponent, |
|||
resolve: { |
|||
extensions: cmsKitAdminExtensionsResolver, |
|||
}, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.Menus', |
|||
replaceableComponent: { |
|||
key: eCmsKitAdminComponents.Menus, |
|||
defaultComponent: MenuItemListComponent, |
|||
}, |
|||
}, |
|||
title: 'CmsKit::MenuItems', |
|||
}, |
|||
{ |
|||
path: 'global-resources', |
|||
component: GlobalResourcesComponent, |
|||
data: { |
|||
requiredPolicy: 'CmsKit.GlobalResources', |
|||
}, |
|||
title: 'CmsKit::GlobalResources', |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
} |
|||
|
|||
function provideCmsKitAdminContributors(options: CmsKitAdminConfigOptions = {}): Provider[] { |
|||
return [ |
|||
{ |
|||
provide: CMS_KIT_ADMIN_ENTITY_ACTION_CONTRIBUTORS, |
|||
useValue: options.entityActionContributors, |
|||
}, |
|||
{ |
|||
provide: CMS_KIT_ADMIN_ENTITY_PROP_CONTRIBUTORS, |
|||
useValue: options.entityPropContributors, |
|||
}, |
|||
{ |
|||
provide: CMS_KIT_ADMIN_TOOLBAR_ACTION_CONTRIBUTORS, |
|||
useValue: options.toolbarActionContributors, |
|||
}, |
|||
{ |
|||
provide: CMS_KIT_ADMIN_CREATE_FORM_PROP_CONTRIBUTORS, |
|||
useValue: options.createFormPropContributors, |
|||
}, |
|||
{ |
|||
provide: CMS_KIT_ADMIN_EDIT_FORM_PROP_CONTRIBUTORS, |
|||
useValue: options.editFormPropContributors, |
|||
}, |
|||
]; |
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
<abp-page [title]="'CmsKit::BlogPosts' | abpLocalization"> |
|||
<div class="card"> |
|||
<div class="card-body"> |
|||
@if (form && (!isEditMode || blogPost)) { |
|||
<form [formGroup]="form" (ngSubmit)="saveAsDraft()" validateOnSubmit> |
|||
<!-- Cover Image --> |
|||
<div class="mb-3"> |
|||
@if (coverImagePreview) { |
|||
<div class="mb-2"> |
|||
<img [src]="coverImagePreview" height="120" alt="Cover Image" /> |
|||
<br /> |
|||
<button type="button" class="btn btn-link p-0" (click)="removeCoverImage()"> |
|||
{{ 'CmsKit::RemoveCoverImage' | abpLocalization }} |
|||
</button> |
|||
<br /> |
|||
</div> |
|||
} |
|||
<label class="form-label" |
|||
>{{ 'CmsKit::CoverImage' | abpLocalization }} |
|||
<span class="badge text-bg-light">16:9</span></label |
|||
> |
|||
<input |
|||
type="file" |
|||
class="form-control" |
|||
accept="image/*" |
|||
(change)="onCoverImageChange($event)" |
|||
/> |
|||
</div> |
|||
|
|||
<!-- Basic fields --> |
|||
<abp-extensible-form [selectedRecord]="blogPost || {}" /> |
|||
|
|||
<!-- Content editor --> |
|||
<div class="mt-3"> |
|||
<label class="form-label">{{ 'CmsKit::Content' | abpLocalization }}</label> |
|||
<abp-toastui-editor formControlName="content" /> |
|||
</div> |
|||
</form> |
|||
|
|||
<!-- Tags Editor --> |
|||
@if (isTagsEnabled) { |
|||
<hr /> |
|||
<div class="mb-3"> |
|||
<label class="form-label">{{ 'CmsKit::Tags' | abpLocalization }}</label> |
|||
<input |
|||
type="text" |
|||
class="form-control" |
|||
[(ngModel)]="tags" |
|||
[ngModelOptions]="{ standalone: true }" |
|||
[placeholder]="'CmsKit::TagsHelpText' | abpLocalization" |
|||
/> |
|||
<div class="form-text">{{ 'CmsKit::TagsHelpText' | abpLocalization }}</div> |
|||
</div> |
|||
} |
|||
} @else { |
|||
<div class="text-center"> |
|||
<i class="fa fa-pulse fa-spinner" aria-hidden="true"></i> |
|||
</div> |
|||
} |
|||
</div> |
|||
<div class="card-footer"> |
|||
<div class="d-flex justify-content-start gap-2"> |
|||
<button class="btn btn-secondary" (click)="saveAsDraft()" [disabled]="form?.invalid"> |
|||
{{ 'CmsKit::SaveAsDraft' | abpLocalization }} |
|||
</button> |
|||
@if (isEditMode) { |
|||
<abp-button (click)="publish()" [disabled]="form?.invalid"> |
|||
{{ 'CmsKit::Publish' | abpLocalization }} |
|||
</abp-button> |
|||
} @else { |
|||
<abp-button (click)="publish()" [disabled]="form?.invalid"> |
|||
{{ 'CmsKit::Publish' | abpLocalization }} |
|||
</abp-button> |
|||
<abp-button (click)="sendToReview()" [disabled]="form?.invalid"> |
|||
{{ 'CmsKit::SendToReviewToPublish' | abpLocalization }} |
|||
</abp-button> |
|||
} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</abp-page> |
|||
@ -0,0 +1,257 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { ActivatedRoute } from '@angular/router'; |
|||
import { Component, OnInit, inject, Injector, DestroyRef } from '@angular/core'; |
|||
import { ReactiveFormsModule, FormsModule, FormGroup, FormControl } from '@angular/forms'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { NgxValidateCoreModule } from '@ngx-validate/core'; |
|||
import { forkJoin, of } from 'rxjs'; |
|||
import { switchMap, tap } from 'rxjs/operators'; |
|||
import { LocalizationPipe, RestService } from '@abp/ng.core'; |
|||
import { |
|||
ExtensibleFormComponent, |
|||
FormPropData, |
|||
generateFormFromProps, |
|||
EXTENSIONS_IDENTIFIER, |
|||
} from '@abp/ng.components/extensible'; |
|||
import { PageComponent } from '@abp/ng.components/page'; |
|||
import { ButtonComponent } from '@abp/ng.theme.shared'; |
|||
import { ToastuiEditorComponent, prepareSlugFromControl } from '@abp/ng.cms-kit'; |
|||
import { |
|||
BlogPostAdminService, |
|||
BlogPostDto, |
|||
MediaDescriptorAdminService, |
|||
EntityTagAdminService, |
|||
CreateMediaInputWithStream, |
|||
TagDto, |
|||
BlogFeatureDto, |
|||
} from '@abp/ng.cms-kit/proxy'; |
|||
import { eCmsKitAdminComponents } from '../../../enums'; |
|||
import { BlogPostFormService } from '../../../services'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-blog-post-form', |
|||
templateUrl: './blog-post-form.component.html', |
|||
providers: [ |
|||
{ |
|||
provide: EXTENSIONS_IDENTIFIER, |
|||
useValue: eCmsKitAdminComponents.BlogPostForm, |
|||
}, |
|||
], |
|||
imports: [ |
|||
ButtonComponent, |
|||
ExtensibleFormComponent, |
|||
PageComponent, |
|||
ToastuiEditorComponent, |
|||
LocalizationPipe, |
|||
ReactiveFormsModule, |
|||
FormsModule, |
|||
CommonModule, |
|||
NgxValidateCoreModule, |
|||
], |
|||
}) |
|||
export class BlogPostFormComponent implements OnInit { |
|||
private blogPostService = inject(BlogPostAdminService); |
|||
private mediaService = inject(MediaDescriptorAdminService); |
|||
private entityTagService = inject(EntityTagAdminService); |
|||
private restService = inject(RestService); |
|||
private injector = inject(Injector); |
|||
private blogPostFormService = inject(BlogPostFormService); |
|||
private route = inject(ActivatedRoute); |
|||
private destroyRef = inject(DestroyRef); |
|||
|
|||
form: FormGroup; |
|||
blogPost: BlogPostDto | null = null; |
|||
blogPostId: string | null = null; |
|||
isEditMode = false; |
|||
coverImageFile: File | null = null; |
|||
coverImagePreview: string | null = null; |
|||
tags: string = ''; |
|||
isTagsEnabled = true; |
|||
|
|||
readonly BLOG_POST_ENTITY_TYPE = 'BlogPost'; |
|||
|
|||
ngOnInit() { |
|||
const id = this.route.snapshot.params['id']; |
|||
if (id) { |
|||
this.isEditMode = true; |
|||
this.blogPostId = id; |
|||
this.loadBlogPost(id); |
|||
} else { |
|||
this.isEditMode = false; |
|||
this.buildForm(); |
|||
} |
|||
} |
|||
|
|||
private loadBlogPost(id: string) { |
|||
this.blogPostService.get(id).subscribe(blogPost => { |
|||
this.blogPost = blogPost; |
|||
if (blogPost.coverImageMediaId) { |
|||
this.coverImagePreview = `/api/cms-kit/media/${blogPost.coverImageMediaId}`; |
|||
} |
|||
this.buildForm(); |
|||
this.loadTags(id); |
|||
}); |
|||
} |
|||
|
|||
private loadTags(blogPostId: string) { |
|||
// TODO: use the public service to load the tags
|
|||
this.restService |
|||
.request<void, TagDto[]>({ |
|||
method: 'GET', |
|||
url: `/api/cms-kit-public/tags/${this.BLOG_POST_ENTITY_TYPE}/${blogPostId}`, |
|||
}) |
|||
.subscribe(tags => { |
|||
if (tags && tags.length > 0) { |
|||
this.tags = tags.map(t => t.name || '').join(', '); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private buildForm() { |
|||
const data = new FormPropData(this.injector, this.blogPost || {}); |
|||
const baseForm = generateFormFromProps(data); |
|||
this.form = new FormGroup({ |
|||
...baseForm.controls, |
|||
content: new FormControl(this.blogPost?.content || ''), |
|||
coverImageMediaId: new FormControl(this.blogPost?.coverImageMediaId || null), |
|||
}); |
|||
prepareSlugFromControl(this.form, 'title', 'slug', this.destroyRef); |
|||
|
|||
// Check if tags feature is enabled for the blog
|
|||
const blogId = this.form.get('blogId')?.value || this.blogPost?.blogId; |
|||
if (blogId) { |
|||
this.checkTagsFeature(blogId); |
|||
} |
|||
|
|||
// Listen for blog selection changes
|
|||
this.form |
|||
.get('blogId') |
|||
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) |
|||
.subscribe(blogId => { |
|||
if (blogId) { |
|||
this.checkTagsFeature(blogId); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private checkTagsFeature(blogId: string) { |
|||
this.restService |
|||
.request<void, BlogFeatureDto>({ |
|||
method: 'GET', |
|||
url: `/api/cms-kit/blogs/${blogId}/features/CmsKit.Tags`, |
|||
}) |
|||
.subscribe(feature => { |
|||
const { isEnabled } = feature || {}; |
|||
this.isTagsEnabled = isEnabled; |
|||
}); |
|||
} |
|||
|
|||
onCoverImageChange(event: Event) { |
|||
const input = event.target as HTMLInputElement; |
|||
if (input.files && input.files[0]) { |
|||
this.coverImageFile = input.files[0]; |
|||
const reader = new FileReader(); |
|||
reader.onload = (e: any) => { |
|||
this.coverImagePreview = e.target.result; |
|||
}; |
|||
reader.readAsDataURL(this.coverImageFile); |
|||
} |
|||
} |
|||
|
|||
removeCoverImage() { |
|||
this.coverImageFile = null; |
|||
this.coverImagePreview = null; |
|||
this.form.patchValue({ coverImageMediaId: null }); |
|||
} |
|||
|
|||
private uploadCoverImage() { |
|||
if (!this.coverImageFile) { |
|||
return of(this.form.value.coverImageMediaId || null); |
|||
} |
|||
|
|||
const input: CreateMediaInputWithStream = { |
|||
name: this.coverImageFile.name, |
|||
file: this.coverImageFile as any, |
|||
}; |
|||
|
|||
return this.mediaService.create('blogpost', input).pipe( |
|||
tap(result => { |
|||
this.form.patchValue({ coverImageMediaId: result.id }); |
|||
}), |
|||
switchMap(result => of(result.id || null)), |
|||
); |
|||
} |
|||
|
|||
private setTags(blogPostId: string) { |
|||
if (!this.tags || !this.tags.trim()) { |
|||
return of(null); |
|||
} |
|||
|
|||
const tagArray = this.tags |
|||
.split(',') |
|||
.map(t => t.trim()) |
|||
.filter(t => t.length > 0); |
|||
|
|||
if (tagArray.length === 0) { |
|||
return of(null); |
|||
} |
|||
|
|||
return this.entityTagService.setEntityTags({ |
|||
entityType: this.BLOG_POST_ENTITY_TYPE, |
|||
entityId: blogPostId, |
|||
tags: tagArray, |
|||
}); |
|||
} |
|||
|
|||
private executeSaveOperation(operation: 'save' | 'draft' | 'publish' | 'sendToReview') { |
|||
// First upload cover image if selected
|
|||
this.uploadCoverImage() |
|||
.pipe( |
|||
tap(coverImageMediaId => { |
|||
if (coverImageMediaId) { |
|||
this.form.patchValue({ coverImageMediaId }); |
|||
} |
|||
}), |
|||
switchMap(() => { |
|||
if (this.isEditMode) { |
|||
if (!this.blogPost || !this.blogPostId) { |
|||
return of(null); |
|||
} |
|||
return this.blogPostFormService.update(this.blogPostId, this.form, this.blogPost); |
|||
} |
|||
|
|||
switch (operation) { |
|||
case 'save': |
|||
case 'draft': |
|||
return this.blogPostFormService.createAsDraft(this.form); |
|||
case 'publish': |
|||
return this.blogPostFormService.createAndPublish(this.form); |
|||
case 'sendToReview': |
|||
return this.blogPostFormService.createAndSendToReview(this.form); |
|||
default: |
|||
return of(null); |
|||
} |
|||
}), |
|||
switchMap(result => { |
|||
if (!result || !result.id) { |
|||
return of(null); |
|||
} |
|||
// Set tags after blog post is created/updated
|
|||
return forkJoin([of(result), this.setTags(result.id)]); |
|||
}), |
|||
) |
|||
.subscribe(); |
|||
} |
|||
|
|||
saveAsDraft() { |
|||
this.executeSaveOperation('draft'); |
|||
} |
|||
|
|||
publish() { |
|||
this.executeSaveOperation('publish'); |
|||
} |
|||
|
|||
sendToReview() { |
|||
this.executeSaveOperation('sendToReview'); |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
<abp-page [title]="'CmsKit::BlogPosts' | abpLocalization" [toolbar]="data.items"> |
|||
<div class="card mb-4"> |
|||
<div class="card-body"> |
|||
<div class="row"> |
|||
<div class="col-2"> |
|||
<select class="form-select" [(ngModel)]="statusFilter" (change)="onStatusChange()"> |
|||
<option [ngValue]="null">{{ 'CmsKit::AllPosts' | abpLocalization }}</option> |
|||
<option [ngValue]="BlogPostStatus.Draft"> |
|||
{{ 'CmsKit::' + 'CmsKit.BlogPost.Status.0' | abpLocalization }} |
|||
</option> |
|||
<option [ngValue]="BlogPostStatus.Published"> |
|||
{{ 'CmsKit::' + 'CmsKit.BlogPost.Status.1' | abpLocalization }} |
|||
</option> |
|||
<option [ngValue]="BlogPostStatus.WaitingForReview"> |
|||
{{ 'CmsKit::' + 'CmsKit.BlogPost.Status.2' | abpLocalization }} |
|||
</option> |
|||
</select> |
|||
</div> |
|||
<div class="col-10"> |
|||
<div class="input-group"> |
|||
<input |
|||
type="search" |
|||
class="form-control" |
|||
[placeholder]="'AbpUi::PagerSearch' | abpLocalization" |
|||
[(ngModel)]="filter" |
|||
(keyup.enter)="onSearch()" |
|||
/> |
|||
<button class="btn btn-primary" type="button" (click)="onSearch()"> |
|||
<i class="fa fa-search" aria-hidden="true"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="card"> |
|||
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" /> |
|||
</div> |
|||
</abp-page> |
|||
@ -0,0 +1,86 @@ |
|||
import { Component, OnInit, inject } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { FormsModule } from '@angular/forms'; |
|||
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core'; |
|||
import { ExtensibleTableComponent, EXTENSIONS_IDENTIFIER } from '@abp/ng.components/extensible'; |
|||
import { Confirmation, ConfirmationService } from '@abp/ng.theme.shared'; |
|||
import { PageComponent } from '@abp/ng.components/page'; |
|||
import { |
|||
BlogPostAdminService, |
|||
BlogPostGetListInput, |
|||
BlogPostListDto, |
|||
BlogPostStatus, |
|||
} from '@abp/ng.cms-kit/proxy'; |
|||
import { eCmsKitAdminComponents } from '../../../enums'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-blog-post-list', |
|||
templateUrl: './blog-post-list.component.html', |
|||
providers: [ |
|||
ListService, |
|||
{ |
|||
provide: EXTENSIONS_IDENTIFIER, |
|||
useValue: eCmsKitAdminComponents.BlogPosts, |
|||
}, |
|||
], |
|||
imports: [ExtensibleTableComponent, PageComponent, LocalizationPipe, FormsModule, CommonModule], |
|||
}) |
|||
export class BlogPostListComponent implements OnInit { |
|||
data: PagedResultDto<BlogPostListDto> = { items: [], totalCount: 0 }; |
|||
|
|||
public readonly list = inject(ListService<BlogPostGetListInput>); |
|||
private blogPostService = inject(BlogPostAdminService); |
|||
private confirmationService = inject(ConfirmationService); |
|||
|
|||
filter = ''; |
|||
statusFilter: BlogPostStatus | null = null; |
|||
BlogPostStatus = BlogPostStatus; |
|||
|
|||
ngOnInit() { |
|||
this.hookToQuery(); |
|||
} |
|||
|
|||
onSearch() { |
|||
this.list.filter = this.filter; |
|||
this.list.get(); |
|||
} |
|||
|
|||
onStatusChange() { |
|||
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) { |
|||
this.confirmationService |
|||
.warn('CmsKit::BlogPostDeletionConfirmationMessage', 'AbpUi::AreYouSure', { |
|||
yesText: 'AbpUi::Yes', |
|||
cancelText: 'AbpUi::Cancel', |
|||
messageLocalizationParams: [title], |
|||
}) |
|||
.subscribe((status: Confirmation.Status) => { |
|||
if (status === Confirmation.Status.confirm) { |
|||
this.blogPostService.delete(id).subscribe(() => { |
|||
this.list.get(); |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,2 @@ |
|||
export * from './blog-post-list/blog-post-list.component'; |
|||
export * from './blog-post-form/blog-post-form.component'; |
|||
@ -0,0 +1,65 @@ |
|||
<abp-modal [visible]="true" (visibleChange)="onVisibleChange($event)"> |
|||
<ng-template #abpHeader> |
|||
<h3>{{ 'CmsKit::Features' | abpLocalization }}</h3> |
|||
</ng-template> |
|||
|
|||
<ng-template #abpBody> |
|||
@if (form) { |
|||
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit> |
|||
<div formArrayName="features"> |
|||
@for (featureControl of featuresFormArray.controls; track $index; let i = $index) { |
|||
<div class="mb-3" [formGroupName]="i"> |
|||
@let isAvailable = featureControl.get('isAvailable')?.value; |
|||
@if (isAvailable) { |
|||
<div class="form-check"> |
|||
<input |
|||
type="checkbox" |
|||
class="form-check-input" |
|||
id="feature-{{ i }}" |
|||
formControlName="isEnabled" |
|||
/> |
|||
<label class="form-check-label" for="feature-{{ i }}"> |
|||
{{ 'CmsKit::' + features[i]?.featureName | abpLocalization }} |
|||
</label> |
|||
</div> |
|||
} @else { |
|||
<div |
|||
data-toggle="tooltip" |
|||
[title]="'CmsKit::BlogFeatureNotAvailable' | abpLocalization" |
|||
> |
|||
<div class="form-check"> |
|||
<input |
|||
type="checkbox" |
|||
class="form-check-input" |
|||
id="feature-{{ i }}" |
|||
formControlName="isEnabled" |
|||
disabled |
|||
/> |
|||
<label class="form-check-label" for="feature-{{ i }}"> |
|||
{{ 'CmsKit::' + features[i]?.featureName | abpLocalization }} |
|||
</label> |
|||
</div> |
|||
</div> |
|||
} |
|||
<input type="hidden" formControlName="featureName" /> |
|||
<input type="hidden" formControlName="isAvailable" /> |
|||
</div> |
|||
} |
|||
</div> |
|||
</form> |
|||
} @else { |
|||
<div class="text-center"> |
|||
<i class="fa fa-pulse fa-spinner" aria-hidden="true"></i> |
|||
</div> |
|||
} |
|||
</ng-template> |
|||
|
|||
<ng-template #abpFooter> |
|||
<button type="button" class="btn btn-outline-primary" abpClose> |
|||
{{ 'AbpUi::Cancel' | abpLocalization }} |
|||
</button> |
|||
<abp-button iconClass="fa fa-check" [disabled]="form?.invalid" (click)="save()"> |
|||
{{ 'AbpUi::Save' | abpLocalization }} |
|||
</abp-button> |
|||
</ng-template> |
|||
</abp-modal> |
|||
@ -0,0 +1,134 @@ |
|||
import { Component, OnInit, inject, input, output } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { ReactiveFormsModule, FormArray, FormBuilder, FormGroup } from '@angular/forms'; |
|||
import { NgxValidateCoreModule } from '@ngx-validate/core'; |
|||
import { forkJoin } from 'rxjs'; |
|||
import { LocalizationPipe } from '@abp/ng.core'; |
|||
import { |
|||
ModalComponent, |
|||
ModalCloseDirective, |
|||
ButtonComponent, |
|||
ToasterService, |
|||
} from '@abp/ng.theme.shared'; |
|||
import { |
|||
BlogFeatureAdminService, |
|||
BlogFeatureDto, |
|||
BlogFeatureInputDto, |
|||
} from '@abp/ng.cms-kit/proxy'; |
|||
|
|||
export interface BlogFeaturesModalVisibleChange { |
|||
visible: boolean; |
|||
refresh: boolean; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'abp-blog-features-modal', |
|||
templateUrl: './blog-features-modal.component.html', |
|||
imports: [ |
|||
LocalizationPipe, |
|||
ReactiveFormsModule, |
|||
CommonModule, |
|||
NgxValidateCoreModule, |
|||
ModalComponent, |
|||
ModalCloseDirective, |
|||
ButtonComponent, |
|||
], |
|||
}) |
|||
export class BlogFeaturesModalComponent implements OnInit { |
|||
private blogFeatureService = inject(BlogFeatureAdminService); |
|||
private fb = inject(FormBuilder); |
|||
private toasterService = inject(ToasterService); |
|||
|
|||
blogId = input<string>(); |
|||
visibleChange = output<BlogFeaturesModalVisibleChange>(); |
|||
|
|||
form: FormGroup; |
|||
features: BlogFeatureDto[] = []; |
|||
private initialFeatureStates: Map<string, boolean> = new Map(); |
|||
|
|||
ngOnInit() { |
|||
if (this.blogId()) { |
|||
this.loadFeatures(); |
|||
} |
|||
} |
|||
|
|||
private loadFeatures() { |
|||
this.blogFeatureService.getList(this.blogId()!).subscribe(features => { |
|||
this.features = features.sort((a, b) => |
|||
(a.featureName || '').localeCompare(b.featureName || ''), |
|||
); |
|||
// Store initial states
|
|||
this.initialFeatureStates = new Map( |
|||
this.features.map(f => [f.featureName || '', f.isEnabled || false]), |
|||
); |
|||
this.buildForm(); |
|||
}); |
|||
} |
|||
|
|||
private buildForm() { |
|||
const featureControls = this.features.map(feature => |
|||
this.fb.group({ |
|||
featureName: [feature.featureName], |
|||
isEnabled: [feature.isEnabled], |
|||
isAvailable: [(feature as any).isAvailable ?? true], |
|||
}), |
|||
); |
|||
|
|||
this.form = this.fb.group({ |
|||
features: this.fb.array(featureControls), |
|||
}); |
|||
} |
|||
|
|||
get featuresFormArray(): FormArray { |
|||
return this.form.get('features') as FormArray; |
|||
} |
|||
|
|||
onVisibleChange(visible: boolean, refresh = false) { |
|||
this.visibleChange.emit({ visible, refresh }); |
|||
} |
|||
|
|||
save() { |
|||
if (!this.form.valid || !this.blogId()) { |
|||
return; |
|||
} |
|||
|
|||
const featuresArray = this.form.get('features') as FormArray; |
|||
|
|||
// Only save features that have changed
|
|||
const changedFeatures: BlogFeatureInputDto[] = featuresArray.controls |
|||
.map(control => { |
|||
const featureName = control.get('featureName')?.value; |
|||
const isEnabled = control.get('isEnabled')?.value; |
|||
const initialIsEnabled = this.initialFeatureStates.get(featureName); |
|||
|
|||
// Only include if the value has changed
|
|||
if (featureName && initialIsEnabled !== isEnabled) { |
|||
return { |
|||
featureName, |
|||
isEnabled, |
|||
}; |
|||
} |
|||
return null; |
|||
}) |
|||
.filter((input): input is BlogFeatureInputDto => input !== null); |
|||
|
|||
// If no features changed, just close the modal
|
|||
if (changedFeatures.length === 0) { |
|||
this.onVisibleChange(false, false); |
|||
return; |
|||
} |
|||
|
|||
// Save only changed features
|
|||
const saveObservables = changedFeatures.map(input => |
|||
this.blogFeatureService.set(this.blogId()!, input), |
|||
); |
|||
|
|||
// Use forkJoin to save all changed features at once
|
|||
forkJoin(saveObservables).subscribe({ |
|||
next: () => { |
|||
this.onVisibleChange(false, true); |
|||
this.toasterService.success('AbpUi::SavedSuccessfully'); |
|||
}, |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
<abp-page [title]="'CmsKit::Blogs' | abpLocalization" [toolbar]="data.items"> |
|||
<div class="card"> |
|||
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" /> |
|||
</div> |
|||
|
|||
@if (isModalVisible) { |
|||
<abp-blog-modal [selected]="selected" (visibleChange)="onVisibleModalChange($event)" /> |
|||
} |
|||
|
|||
@if (isFeaturesModalVisible) { |
|||
<abp-blog-features-modal |
|||
[blogId]="selectedBlogId" |
|||
(visibleChange)="onFeaturesModalChange($event)" |
|||
/> |
|||
} |
|||
</abp-page> |
|||
@ -0,0 +1,128 @@ |
|||
import { Component, OnInit, inject } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { FormsModule } from '@angular/forms'; |
|||
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core'; |
|||
import { Confirmation, ConfirmationService } from '@abp/ng.theme.shared'; |
|||
import { ExtensibleTableComponent, EXTENSIONS_IDENTIFIER } from '@abp/ng.components/extensible'; |
|||
import { PageComponent } from '@abp/ng.components/page'; |
|||
import { BlogAdminService, BlogGetListInput, BlogDto } from '@abp/ng.cms-kit/proxy'; |
|||
import { eCmsKitAdminComponents } from '../../../enums'; |
|||
import { BlogModalComponent, BlogModalVisibleChange } from '../blog-modal/blog-modal.component'; |
|||
import { |
|||
BlogFeaturesModalComponent, |
|||
BlogFeaturesModalVisibleChange, |
|||
} from '../blog-features-modal/blog-features-modal.component'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-blog-list', |
|||
templateUrl: './blog-list.component.html', |
|||
providers: [ |
|||
ListService, |
|||
{ |
|||
provide: EXTENSIONS_IDENTIFIER, |
|||
useValue: eCmsKitAdminComponents.Blogs, |
|||
}, |
|||
], |
|||
imports: [ |
|||
ExtensibleTableComponent, |
|||
PageComponent, |
|||
LocalizationPipe, |
|||
FormsModule, |
|||
CommonModule, |
|||
BlogModalComponent, |
|||
BlogFeaturesModalComponent, |
|||
], |
|||
}) |
|||
export class BlogListComponent implements OnInit { |
|||
data: PagedResultDto<BlogDto> = { items: [], totalCount: 0 }; |
|||
|
|||
public readonly list = inject(ListService<BlogGetListInput>); |
|||
private blogService = inject(BlogAdminService); |
|||
private confirmationService = inject(ConfirmationService); |
|||
|
|||
filter = ''; |
|||
isModalVisible = false; |
|||
selected?: BlogDto; |
|||
isFeaturesModalVisible = false; |
|||
selectedBlogId?: string; |
|||
|
|||
ngOnInit() { |
|||
this.hookToQuery(); |
|||
} |
|||
|
|||
onSearch() { |
|||
this.list.filter = this.filter; |
|||
this.list.get(); |
|||
} |
|||
|
|||
add() { |
|||
this.selected = {} as BlogDto; |
|||
this.isModalVisible = true; |
|||
} |
|||
|
|||
edit(id: string) { |
|||
this.blogService.get(id).subscribe(blog => { |
|||
this.selected = blog; |
|||
this.isModalVisible = true; |
|||
}); |
|||
} |
|||
|
|||
delete(id: string, name: string) { |
|||
this.confirmationService |
|||
.warn('CmsKit::BlogDeletionConfirmationMessage', 'AbpUi::AreYouSure', { |
|||
messageLocalizationParams: [name], |
|||
}) |
|||
.subscribe((status: Confirmation.Status) => { |
|||
if (status === Confirmation.Status.confirm) { |
|||
this.blogService.delete(id).subscribe(() => { |
|||
this.list.get(); |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
openFeatures(id: string) { |
|||
this.selectedBlogId = id; |
|||
this.isFeaturesModalVisible = 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) { |
|||
if (visibilityChange.visible) { |
|||
return; |
|||
} |
|||
if (visibilityChange.refresh) { |
|||
this.list.get(); |
|||
} |
|||
this.selected = null; |
|||
this.isModalVisible = false; |
|||
} |
|||
|
|||
onFeaturesModalChange(visibilityChange: BlogFeaturesModalVisibleChange) { |
|||
if (visibilityChange.visible) { |
|||
return; |
|||
} |
|||
if (visibilityChange.refresh) { |
|||
this.list.get(); |
|||
} |
|||
this.selectedBlogId = null; |
|||
this.isFeaturesModalVisible = false; |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
<abp-modal [visible]="true" (visibleChange)="onVisibleChange($event)"> |
|||
<ng-template #abpHeader> |
|||
<h3> |
|||
{{ (selected()?.id ? 'AbpUi::Edit' : 'AbpUi::New') | abpLocalization }} |
|||
</h3> |
|||
</ng-template> |
|||
|
|||
<ng-template #abpBody> |
|||
@if (form) { |
|||
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit> |
|||
<abp-extensible-form [selectedRecord]="selected()" /> |
|||
</form> |
|||
} @else { |
|||
<div class="text-center"> |
|||
<i class="fa fa-pulse fa-spinner" aria-hidden="true"></i> |
|||
</div> |
|||
} |
|||
</ng-template> |
|||
|
|||
<ng-template #abpFooter> |
|||
<button type="button" class="btn btn-outline-primary" abpClose> |
|||
{{ 'AbpUi::Cancel' | abpLocalization }} |
|||
</button> |
|||
<abp-button iconClass="fa fa-check" [disabled]="form?.invalid" (click)="save()"> |
|||
{{ 'AbpUi::Save' | abpLocalization }} |
|||
</abp-button> |
|||
</ng-template> |
|||
</abp-modal> |
|||
@ -0,0 +1,86 @@ |
|||
import { Component, OnInit, inject, Injector, input, output, DestroyRef } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { ReactiveFormsModule, FormGroup } from '@angular/forms'; |
|||
import { NgxValidateCoreModule } from '@ngx-validate/core'; |
|||
import { LocalizationPipe } from '@abp/ng.core'; |
|||
import { |
|||
ExtensibleFormComponent, |
|||
FormPropData, |
|||
generateFormFromProps, |
|||
} from '@abp/ng.components/extensible'; |
|||
import { |
|||
ModalComponent, |
|||
ModalCloseDirective, |
|||
ButtonComponent, |
|||
ToasterService, |
|||
} from '@abp/ng.theme.shared'; |
|||
import { BlogAdminService, BlogDto, CreateBlogDto, UpdateBlogDto } from '@abp/ng.cms-kit/proxy'; |
|||
import { prepareSlugFromControl } from '@abp/ng.cms-kit'; |
|||
|
|||
export interface BlogModalVisibleChange { |
|||
visible: boolean; |
|||
refresh: boolean; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'abp-blog-modal', |
|||
templateUrl: './blog-modal.component.html', |
|||
imports: [ |
|||
ExtensibleFormComponent, |
|||
LocalizationPipe, |
|||
ReactiveFormsModule, |
|||
CommonModule, |
|||
NgxValidateCoreModule, |
|||
ModalComponent, |
|||
ModalCloseDirective, |
|||
ButtonComponent, |
|||
], |
|||
}) |
|||
export class BlogModalComponent implements OnInit { |
|||
private blogService = inject(BlogAdminService); |
|||
private injector = inject(Injector); |
|||
private toasterService = inject(ToasterService); |
|||
private destroyRef = inject(DestroyRef); |
|||
|
|||
selected = input<BlogDto>(); |
|||
visibleChange = output<BlogModalVisibleChange>(); |
|||
|
|||
form: FormGroup; |
|||
|
|||
ngOnInit() { |
|||
this.buildForm(); |
|||
} |
|||
|
|||
private buildForm() { |
|||
const data = new FormPropData(this.injector, this.selected()); |
|||
this.form = generateFormFromProps(data); |
|||
prepareSlugFromControl(this.form, 'name', 'slug', this.destroyRef); |
|||
} |
|||
|
|||
onVisibleChange(visible: boolean, refresh = false) { |
|||
this.visibleChange.emit({ visible, refresh }); |
|||
} |
|||
|
|||
save() { |
|||
if (!this.form.valid) { |
|||
return; |
|||
} |
|||
|
|||
let observable$ = this.blogService.create(this.form.value as CreateBlogDto); |
|||
|
|||
const selectedBlog = this.selected(); |
|||
const { id } = selectedBlog || {}; |
|||
|
|||
if (id) { |
|||
observable$ = this.blogService.update(id, { |
|||
...selectedBlog, |
|||
...this.form.value, |
|||
} as UpdateBlogDto); |
|||
} |
|||
|
|||
observable$.subscribe(() => { |
|||
this.onVisibleChange(false, true); |
|||
this.toasterService.success('AbpUi::SavedSuccessfully'); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
export * from './blog-list/blog-list.component'; |
|||
export * from './blog-modal/blog-modal.component'; |
|||
export * from './blog-features-modal/blog-features-modal.component'; |
|||
@ -0,0 +1,42 @@ |
|||
<div id="CmsSettingsForm" class="row"> |
|||
<div class="col-md-12"> |
|||
<ul ngbNav #nav="ngbNav" class="nav-tabs"> |
|||
<!-- Comments --> |
|||
<li ngbNavItem> |
|||
<a ngbNavLink>{{ 'CmsKit::CmsKit:Comment' | abpLocalization }}</a> |
|||
<ng-template ngbNavContent> |
|||
<div class="abp-md-form"> |
|||
<div (keyup.enter)="submit()"> |
|||
<div class="mt-3"> |
|||
<div class="form-check mb-2"> |
|||
<input |
|||
class="form-check-input" |
|||
[formControl]="commentApprovalControl" |
|||
type="checkbox" |
|||
id="EnableCommentApproval" |
|||
[checked]="commentApprovalControl.value" |
|||
(input)="commentApprovalControl.setValue($event.target.checked)" |
|||
/> |
|||
<label class="form-check-label" for="EnableCommentApproval"> |
|||
{{ 'CmsKit::CmsKitCommentOptions:RequireApprovement' | abpLocalization }} |
|||
</label> |
|||
<br /> |
|||
<small class="form-text text-muted">{{ |
|||
'CmsKit::CmsKitCommentOptions:RequireApprovementDescription' | abpLocalization |
|||
}}</small> |
|||
</div> |
|||
</div> |
|||
<hr class="my-3" /> |
|||
<div> |
|||
<abp-button (click)="submit()" iconClass="fa fa-save" [loading]="loading"> |
|||
{{ 'AbpAccount::Save' | abpLocalization }} |
|||
</abp-button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</ng-template> |
|||
</li> |
|||
</ul> |
|||
<div [ngbNavOutlet]="nav"></div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,59 @@ |
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; |
|||
import { FormControl, ReactiveFormsModule } from '@angular/forms'; |
|||
import { |
|||
NgbNav, |
|||
NgbNavItem, |
|||
NgbNavItemRole, |
|||
NgbNavLink, |
|||
NgbNavLinkBase, |
|||
NgbNavContent, |
|||
NgbNavOutlet, |
|||
} from '@ng-bootstrap/ng-bootstrap'; |
|||
import { finalize } from 'rxjs/operators'; |
|||
import { ConfigStateService, LocalizationPipe } from '@abp/ng.core'; |
|||
import { ButtonComponent, ToasterService } from '@abp/ng.theme.shared'; |
|||
import { CommentAdminService } from '@abp/ng.cms-kit/proxy'; |
|||
|
|||
import { CMS_KIT_COMMENTS_REQUIRE_APPROVEMENT } from '../comments'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-cms-settings', |
|||
templateUrl: './cms-settings.component.html', |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
imports: [ |
|||
NgbNav, |
|||
NgbNavItem, |
|||
NgbNavItemRole, |
|||
NgbNavLink, |
|||
NgbNavLinkBase, |
|||
NgbNavContent, |
|||
NgbNavOutlet, |
|||
LocalizationPipe, |
|||
ButtonComponent, |
|||
ReactiveFormsModule, |
|||
], |
|||
}) |
|||
export class CmsSettingsComponent { |
|||
readonly commentAdminService = inject(CommentAdminService); |
|||
readonly configState = inject(ConfigStateService); |
|||
readonly toaster = inject(ToasterService); |
|||
|
|||
commentApprovalControl = new FormControl(false); |
|||
|
|||
ngOnInit() { |
|||
const isCommentApprovalEnabled = |
|||
this.configState.getSetting(CMS_KIT_COMMENTS_REQUIRE_APPROVEMENT).toLowerCase() === 'true'; |
|||
this.commentApprovalControl.setValue(isCommentApprovalEnabled); |
|||
} |
|||
|
|||
submit() { |
|||
this.commentAdminService |
|||
.updateSettings({ commentRequireApprovement: this.commentApprovalControl.value }) |
|||
.pipe( |
|||
finalize(() => { |
|||
this.configState.refreshAppState().subscribe(); |
|||
}), |
|||
) |
|||
.subscribe(() => this.toaster.success('AbpUi::SavedSuccessfully', null)); |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './cms-settings.component'; |
|||
@ -0,0 +1,147 @@ |
|||
<abp-page [title]="'CmsKit::Comments' | abpLocalization"> |
|||
@if (comment) { |
|||
<div class="card mb-4"> |
|||
<div class="card-body"> |
|||
<table class="table"> |
|||
<tr> |
|||
<td width="10%"> |
|||
<b>{{ 'CmsKit::EntityType' | abpLocalization }}</b |
|||
>: |
|||
</td> |
|||
<td>{{ comment.entityType }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td> |
|||
<b>{{ 'CmsKit::EntityId' | abpLocalization }}</b |
|||
>: |
|||
</td> |
|||
<td>{{ comment.entityId }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td> |
|||
<b>{{ 'AbpIdentity::CreationTime' | abpLocalization }}</b |
|||
>: |
|||
</td> |
|||
<td>{{ comment.creationTime | date }}</td> |
|||
</tr> |
|||
<tr> |
|||
<td> |
|||
<b>{{ 'CmsKit::Username' | abpLocalization }}</b |
|||
>: |
|||
</td> |
|||
<td>{{ comment.author?.name }}</td> |
|||
</tr> |
|||
@if (comment.repliedCommentId) { |
|||
<tr> |
|||
<td> |
|||
<b>{{ 'CmsKit::ReplyTo' | abpLocalization }}</b |
|||
>: |
|||
</td> |
|||
<td> |
|||
<a (click)="navigateToReply(comment.repliedCommentId!)" style="cursor: pointer"> |
|||
{{ comment.repliedCommentId }} |
|||
</a> |
|||
</td> |
|||
</tr> |
|||
} |
|||
<tr> |
|||
<td class="align-text-top"> |
|||
<b>{{ 'CmsKit::Text' | abpLocalization }}</b |
|||
>: |
|||
</td> |
|||
<td>{{ comment.text }}</td> |
|||
</tr> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
<div class="card mb-4"> |
|||
<div class="card-body"> |
|||
<form [formGroup]="filterForm" (ngSubmit)="onFilter()"> |
|||
<input type="hidden" [value]="commentId" /> |
|||
<div class="row"> |
|||
<div class="col-lg-4 col-md-12"> |
|||
<div class="row"> |
|||
<div class="col-lg-6 col-md-6"> |
|||
<div class="mb-3"> |
|||
<label class="form-label" for="creationStartDate"> |
|||
{{ 'CmsKit::StartDate' | abpLocalization }} |
|||
</label> |
|||
<input |
|||
id="creationStartDate" |
|||
formControlName="creationStartDate" |
|||
(click)="startDatePicker.open()" |
|||
(keyup.space)="startDatePicker.open()" |
|||
ngbDatepicker |
|||
#startDatePicker="ngbDatepicker" |
|||
type="text" |
|||
class="form-control" |
|||
/> |
|||
</div> |
|||
</div> |
|||
<div class="col-lg-6 col-md-6"> |
|||
<div class="mb-3"> |
|||
<label class="form-label" for="creationEndDate"> |
|||
{{ 'CmsKit::EndDate' | abpLocalization }} |
|||
</label> |
|||
<input |
|||
id="creationEndDate" |
|||
formControlName="creationEndDate" |
|||
class="form-control" |
|||
ngbDatepicker |
|||
#endDatePicker="ngbDatepicker" |
|||
(click)="endDatePicker.toggle()" |
|||
readonly |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="col-lg-4 col-md-12"> |
|||
<abp-form-input |
|||
formControlName="author" |
|||
[label]="'CmsKit::Username' | abpLocalization" |
|||
type="text" |
|||
/> |
|||
</div> |
|||
@if (requireApprovement) { |
|||
<div class="col-lg-2 col-md-12"> |
|||
<div class="mb-3"> |
|||
<label class="mb-1 form-label" for="commentApproveState"> |
|||
{{ 'CmsKit::CommentFilter:ApproveState' | abpLocalization }} |
|||
</label> |
|||
<select |
|||
id="commentApproveState" |
|||
formControlName="commentApproveState" |
|||
class="form-control" |
|||
> |
|||
@for (option of commentApproveStateOptions; track option.value) { |
|||
<option [value]="option.value"> |
|||
{{ 'CmsKit::CommentFilter:' + option.value | abpLocalization }} |
|||
</option> |
|||
} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
<div class="col-lg-2 col-md-12"> |
|||
<div class="d-grid gap-2"> |
|||
<abp-button class="mt-md-4" buttonType="Primary" type="submit"> |
|||
<i class="fa fa-search" aria-hidden="true"></i> |
|||
</abp-button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
|
|||
<h3>{{ 'CmsKit::RepliesToThisComment' | abpLocalization }}</h3> |
|||
|
|||
<div class="card"> |
|||
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" /> |
|||
</div> |
|||
</abp-page> |
|||
@ -0,0 +1,124 @@ |
|||
import { Component, OnInit, inject } from '@angular/core'; |
|||
import { CommonModule, DatePipe } from '@angular/common'; |
|||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
import { NgbDateAdapter, NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; |
|||
import { ListService, LocalizationPipe, PagedResultDto } from '@abp/ng.core'; |
|||
import { PageComponent } from '@abp/ng.components/page'; |
|||
import { ExtensibleTableComponent, EXTENSIONS_IDENTIFIER } from '@abp/ng.components/extensible'; |
|||
import { ButtonComponent, DateTimeAdapter, FormInputComponent } from '@abp/ng.theme.shared'; |
|||
import { |
|||
CommentAdminService, |
|||
CommentGetListInput, |
|||
CommentWithAuthorDto, |
|||
CommentApproveState, |
|||
commentApproveStateOptions, |
|||
} from '@abp/ng.cms-kit/proxy'; |
|||
import { eCmsKitAdminComponents } from '../../../enums'; |
|||
import { CommentEntityService } from '../../../services'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-comment-details', |
|||
templateUrl: './comment-details.component.html', |
|||
providers: [ |
|||
ListService, |
|||
{ |
|||
provide: EXTENSIONS_IDENTIFIER, |
|||
useValue: eCmsKitAdminComponents.CommentDetails, |
|||
}, |
|||
], |
|||
viewProviders: [ |
|||
{ |
|||
provide: NgbDateAdapter, |
|||
useClass: DateTimeAdapter, |
|||
}, |
|||
], |
|||
imports: [ |
|||
ExtensibleTableComponent, |
|||
PageComponent, |
|||
LocalizationPipe, |
|||
ReactiveFormsModule, |
|||
NgbDatepickerModule, |
|||
CommonModule, |
|||
DatePipe, |
|||
FormInputComponent, |
|||
ButtonComponent, |
|||
], |
|||
}) |
|||
export class CommentDetailsComponent implements OnInit { |
|||
comment: CommentWithAuthorDto | null = null; |
|||
data: PagedResultDto<CommentWithAuthorDto> = { items: [], totalCount: 0 }; |
|||
|
|||
readonly list = inject(ListService<CommentGetListInput>); |
|||
readonly commentEntityService = inject(CommentEntityService); |
|||
|
|||
private commentService = inject(CommentAdminService); |
|||
private route = inject(ActivatedRoute); |
|||
private router = inject(Router); |
|||
private fb = inject(FormBuilder); |
|||
|
|||
filterForm!: FormGroup; |
|||
commentApproveStateOptions = commentApproveStateOptions; |
|||
commentId!: string; |
|||
requireApprovement: boolean; |
|||
|
|||
ngOnInit() { |
|||
this.route.params.subscribe(params => { |
|||
const id = params['id']; |
|||
if (id) { |
|||
this.commentId = id; |
|||
this.loadComment(id); |
|||
this.createFilterForm(); |
|||
this.hookToQuery(); |
|||
} |
|||
}); |
|||
this.requireApprovement = this.commentEntityService.requireApprovement; |
|||
} |
|||
|
|||
private createFilterForm() { |
|||
this.filterForm = this.fb.group({ |
|||
creationStartDate: [null], |
|||
creationEndDate: [null], |
|||
author: [''], |
|||
commentApproveState: [CommentApproveState.All], |
|||
}); |
|||
} |
|||
|
|||
private loadComment(id: string) { |
|||
this.commentService.get(id).subscribe(comment => { |
|||
this.comment = comment; |
|||
}); |
|||
} |
|||
|
|||
onFilter() { |
|||
const formValue = this.filterForm.value; |
|||
const filters: Partial<CommentGetListInput> = { |
|||
author: formValue.author || undefined, |
|||
commentApproveState: formValue.commentApproveState, |
|||
repliedCommentId: this.commentId, |
|||
creationStartDate: formValue.creationStartDate || undefined, |
|||
creationEndDate: formValue.creationEndDate || undefined, |
|||
}; |
|||
|
|||
this.list.filter = filters as any; |
|||
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) { |
|||
this.router.navigate(['/cms/comments', id]); |
|||
} |
|||
} |
|||
@ -0,0 +1,92 @@ |
|||
<abp-page [title]="'CmsKit::Comments' | abpLocalization"> |
|||
<div class="card mb-4"> |
|||
<div class="card-body"> |
|||
<form [formGroup]="filterForm" (ngSubmit)="onFilter()"> |
|||
<div class="row align-items-end"> |
|||
<div class="col-lg-4 col-md-12"> |
|||
<div class="row"> |
|||
<div class="col-lg-6 col-md-6"> |
|||
<div class="mb-3"> |
|||
<label class="form-label" for="creationStartDate"> |
|||
{{ 'CmsKit::StartDate' | abpLocalization }} |
|||
</label> |
|||
<input |
|||
id="creationStartDate" |
|||
formControlName="creationStartDate" |
|||
(click)="startDatePicker.open()" |
|||
(keyup.space)="startDatePicker.open()" |
|||
ngbDatepicker |
|||
#startDatePicker="ngbDatepicker" |
|||
type="text" |
|||
class="form-control" |
|||
/> |
|||
</div> |
|||
</div> |
|||
<div class="col-lg-6 col-md-6"> |
|||
<div class="mb-3"> |
|||
<label class="form-label" for="creationEndDate"> |
|||
{{ 'CmsKit::EndDate' | abpLocalization }} |
|||
</label> |
|||
<input |
|||
id="creationEndDate" |
|||
formControlName="creationEndDate" |
|||
class="form-control" |
|||
ngbDatepicker |
|||
#endDatePicker="ngbDatepicker" |
|||
(click)="endDatePicker.toggle()" |
|||
readonly |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="col-lg-2 col-md-6"> |
|||
<abp-form-input |
|||
formControlName="author" |
|||
[label]="'CmsKit::Username' | abpLocalization" |
|||
type="text" |
|||
/> |
|||
</div> |
|||
|
|||
<div class="col-lg-2 col-md-6"> |
|||
<abp-form-input |
|||
formControlName="entityType" |
|||
[label]="'CmsKit::EntityType' | abpLocalization" |
|||
type="text" |
|||
/> |
|||
</div> |
|||
@if (requireApprovement) { |
|||
<div class="col-lg-2 col-md-6"> |
|||
<div class="mb-3"> |
|||
<label class="mb-1 form-label" for="commentApproveState"> |
|||
{{ 'CmsKit::CommentFilter:ApproveState' | abpLocalization }} |
|||
</label> |
|||
<select |
|||
id="commentApproveState" |
|||
formControlName="commentApproveState" |
|||
class="form-control" |
|||
> |
|||
@for (option of commentApproveStateOptions; track option.value) { |
|||
<option [value]="option.value"> |
|||
{{ 'CmsKit::CommentFilter:' + option.value | abpLocalization }} |
|||
</option> |
|||
} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
} |
|||
<div class="col-lg-2 col-md-6"> |
|||
<abp-button class="w-100 mb-3" buttonType="Primary" type="submit"> |
|||
<i class="fa fa-search" aria-hidden="true"></i> |
|||
</abp-button> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="card"> |
|||
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" /> |
|||
</div> |
|||
</abp-page> |
|||
@ -0,0 +1,101 @@ |
|||
import { Component, OnInit, inject } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; |
|||
import { NgbDateAdapter, NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; |
|||
import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core'; |
|||
import { ExtensibleModule, EXTENSIONS_IDENTIFIER } from '@abp/ng.components/extensible'; |
|||
import { PageModule } from '@abp/ng.components/page'; |
|||
import { ButtonComponent, DateTimeAdapter, FormInputComponent } from '@abp/ng.theme.shared'; |
|||
import { |
|||
CommentAdminService, |
|||
CommentGetListInput, |
|||
CommentWithAuthorDto, |
|||
CommentApproveState, |
|||
commentApproveStateOptions, |
|||
} from '@abp/ng.cms-kit/proxy'; |
|||
import { eCmsKitAdminComponents } from '../../../enums'; |
|||
import { CommentEntityService } from '../../../services'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-comment-list', |
|||
templateUrl: './comment-list.component.html', |
|||
providers: [ |
|||
ListService, |
|||
{ |
|||
provide: EXTENSIONS_IDENTIFIER, |
|||
useValue: eCmsKitAdminComponents.CommentList, |
|||
}, |
|||
], |
|||
viewProviders: [ |
|||
{ |
|||
provide: NgbDateAdapter, |
|||
useClass: DateTimeAdapter, |
|||
}, |
|||
], |
|||
imports: [ |
|||
ExtensibleModule, |
|||
PageModule, |
|||
ReactiveFormsModule, |
|||
NgbDatepickerModule, |
|||
CommonModule, |
|||
LocalizationPipe, |
|||
FormInputComponent, |
|||
ButtonComponent, |
|||
], |
|||
}) |
|||
export class CommentListComponent implements OnInit { |
|||
data: PagedResultDto<CommentWithAuthorDto> = { items: [], totalCount: 0 }; |
|||
|
|||
readonly list = inject(ListService<CommentGetListInput>); |
|||
readonly commentEntityService = inject(CommentEntityService); |
|||
|
|||
private commentService = inject(CommentAdminService); |
|||
private fb = inject(FormBuilder); |
|||
|
|||
filterForm!: FormGroup; |
|||
commentApproveStateOptions = commentApproveStateOptions; |
|||
requireApprovement: boolean; |
|||
|
|||
ngOnInit() { |
|||
this.createFilterForm(); |
|||
this.hookToQuery(); |
|||
this.requireApprovement = this.commentEntityService.requireApprovement; |
|||
} |
|||
|
|||
private createFilterForm() { |
|||
this.filterForm = this.fb.group({ |
|||
creationStartDate: [null], |
|||
creationEndDate: [null], |
|||
author: [''], |
|||
entityType: [''], |
|||
commentApproveState: [CommentApproveState.All], |
|||
}); |
|||
} |
|||
|
|||
onFilter() { |
|||
const formValue = this.filterForm.value; |
|||
const filters: Partial<CommentGetListInput> = { |
|||
author: formValue.author || undefined, |
|||
entityType: formValue.entityType || undefined, |
|||
commentApproveState: formValue.commentApproveState, |
|||
creationStartDate: formValue.creationStartDate || undefined, |
|||
creationEndDate: formValue.creationEndDate || undefined, |
|||
}; |
|||
|
|||
this.list.filter = filters as any; |
|||
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)); |
|||
} |
|||
} |
|||