Browse Source

Update volosoft-presentation.pptx

pull/25711/head
Ebicoglu 3 months ago
parent
commit
25b5b7b929
  1. BIN
      docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/volosoft-presentation.pptx
  2. 216
      docs/en/Community-Articles/2026-06-29-customizing-the-abp-framework/POST.md
  3. 6
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs
  4. 6
      framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs
  5. 6
      framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs
  6. 37
      framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs
  7. 200
      framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs
  8. 60
      framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs
  9. 16
      modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs

BIN
docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/volosoft-presentation.pptx

Binary file not shown.

216
docs/en/Community-Articles/2026-06-29-customizing-the-abp-framework/POST.md

@ -0,0 +1,216 @@
# Customizing the ABP Framework: A Developer's Guide to LeptonX Theme Overrides in Angular and the Transition to React UI
Enterprise ASP.NET Boilerplate (ABP) projects rarely stay with default theme behavior for long. At some point, teams need stricter brand alignment, user experience (UX) consistency across modules, or product-specific shell behavior that goes beyond palette and typography tweaks.
This article explains a practical way to customize the LeptonX theme in Angular projects through two primary layers :
1. **Style Overriding:** Utilizing design tokens, global CSS custom properties (variables), and component-level styling.
2. **Element Overriding:** Replacing or extending UI fragments and layout pieces using ABP's built-in services.
Finally, we connect this customization mindset to ABP’s new React direction, where application development teams own more of the user interface (UI) implementation directly from day one.
## Why Overriding Matters in Real ABP Solutions
In enterprise software engineering, frontend customization is not a cosmetic task. Instead, it directly supports core technical and architectural goals :
- **Brand System Compliance:** Enforcing strict color palettes, layouts, and typography across tenant-facing portals and internal back-office administration pages.
- **Accessibility (a11y) Improvements:** Optimizing focus states, color contrast ratios, screen reader compatibility, and keyboard navigation to meet WCAG standards.
- **Product Differentiation:** Structuring distinct top-level layouts, sidebar behavior, and navigation elements to separate multiple products within the same suite.
- **Operational Usability:** Reorganizing application spaces to match domain-specific workflows and simplify intensive data-entry tasks.
To avoid building fragile CSS overrides that break during framework updates, development teams must follow a strict, highly structured hierarchy of customization :
| Level | Customization Type | Technical Mechanism | Strategic Role |
| :---: | :--- | :--- | :--- |
| **1** | **Token-Level Variables** | CSS Custom Properties | 🛡️ *First Line of Defense* |
| **2** | **Component-Style Patch** | Class-Based Overrides | 🎨 *Moderate Visual Tweaks* |
| **3** | **Element Replacement** | ReplaceableComponents | 🏗️ *Deep Structural Overrides* |
Adhering to this hierarchy reduces "style debt" and ensures that theme upgrades remain manageable throughout the application lifecycle.
### Layer 1: Style Overriding in LeptonX (Angular)
Style overriding is the safest and most maintainable way to alter your application's presentation layer. The LeptonX engine relies heavily on CSS custom properties (variables) defined at the `:root` level.
### Customizing Brand Colors and Typography Tokens
To modify the default colors and branding assets, developers can define custom properties within the global `src/styles.scss` file :
```scss
:root {
/* Set the primary brand color used on active elements, buttons, and focuses */
--lpx-brand: #1e3a8a;
/* Set the physical paths for the application logos */
--lpx-logo: url('/assets/images/logo.png');
--lpx-logo-icon: url('/assets/images/logo-icon.png'); /* Displayed when sidebar is collapsed */
}
```
For applications utilizing multi-theme layouts (such as LeptonX Pro's Light, Dark, or Dim modes), variables can be scoped under individual theme classes to dynamically swap brand colors or assets :
```scss
/* Scoping theme-specific logos to prevent visibility issues on dark backgrounds */
:root.lpx-theme-dark, :root.lpx-theme-dim {
--lpx-logo: url('/assets/images/logo-light.png');
--lpx-logo-icon: url('/assets/images/logo-icon-light.png');
}
```
#### Solving the "Visual Branding Blink" on Initial Page Load
A common issue in production occurs when the default LeptonX logo is briefly displayed on screen before the client browser parses the custom stylesheet. This latency creates a noticeable "blink" or flicker.
To eliminate this rendering gap, bypass the CSS variable load phase by replacing the physical logo assets inside the web host project's public directory. Write your custom branding files directly to `/images/logo/leptonx/logo-light.png` inside the server's public folder. Because the fallback variable defaults directly to this location, the client browser displays the custom logo asset immediately without waiting to parse the custom CSS rules.
Additionally, note that styles registered solely in the application's global `styles.scss` may fail to apply to the **Account Layout** (such as the standard login page) because it compiles within an isolated module lifecycle. To ensure your styling overrides apply globally, register the assets and styles in the Virtual File System (VFS) of the.NET backend host, making them universally accessible across all client routing contexts.
### Layer 2: Element Overriding in LeptonX (Angular)
When CSS modifications cannot support your required user experience (such as adding search interfaces, custom profile controls, or custom action layouts), teams must override the underlying UI elements.
ABP provides the `ReplaceableComponentsService` to dynamically replace pre-built layout pieces with custom, project-owned Angular components without breaking core module logic.
### Troubleshooting the Mobile User Profile Freeze
In compiled editions of the LeptonX Lite layout library (specifically versions 3.1.x through 4.3.1), developers have identified a rendering bug affecting mobile layouts. When a user logs in via a mobile device and taps the profile dropdown menu, the page freezes. Instead of displaying the profile options, the sidebar area recursively renders a duplicate copy of the active route page. This layout loop completely breaks navigation until the page is refreshed.
The root cause is a layout bug inside the compiled LeptonX library template (`mn-user-profile.component.html`), where the template markup is wrapped inside an `<ng-component>` tag instead of a structurally neutral `<ng-container>` tag.
To resolve this issue, you can implement a custom component replacement :
1. Generate a custom mobile profile component using the Angular CLI
```bash
ng g component components/my-mobile-profile
```
2. Implement the component template, ensuring the wrapper elements utilize `<ng-container>` instead of `<ng-component>`.
3. Inject the `ReplaceableComponentsService` into your root `app.component.ts` to swap the underlying component keys during application bootstrap :
```tsx
import { Component, OnInit } from '@angular/core';
import { ReplaceableComponentsService } from '@abp/ng.core';
import { eThemeLeptonXComponents } from '@volosoft/ngx-lepton-x';
import { MyMobileUserProfileComponent } from './components/my-mobile-profile.component';
@Component({
selector: 'app-root',
template: '<abp-dynamic-layout />'
})
export class AppComponent implements OnInit{
private replaceableComponents = inject(ReplaceableComponentsService);
ngOnInit() {
this.replaceableComponents.add({
component: MyMobileUserProfileComponent,
key: eThemeLeptonXComponents.MobileUserProfile
});
}
}
```
### Template Context: From LeptonX Demo Setup to Real ABP Application Templates
When transitioning customized designs from local prototypes to production environments, development teams must choose between two operating modes :
| **Operational Mode** | **Core Architecture** | **Rationale & Trade-offs** |
| --- | --- | --- |
| **Standard Template Mode** | Consumes LeptonX packages as standard dependencies (`@abp/ng.theme.lepton-x`) from npm registries. All overrides are applied at the application layer. | **Highly Recommended.** Keeps local project codebases clean, simplifies dependency updates, and avoids style debt. |
| **Source-Inspection Mode** | Utilizes the ABP CLI `get-source` command to download the raw theme code and configure temporary local path aliases. | **Diagnostic Only.** Best used for deep debugging, prototyping layout behaviors, or tracing framework-level bugs. |
### Resolving Strict MIME Type CSS Loading Exceptions
During local development or initial production deployments of LeptonX Lite Angular applications, browsers may refuse to apply the theme's styles. This issue manifests as a console exception:
`Refused to apply style from 'http://localhost:4200/bootstrap-dim.css' because its MIME type ('text/html') is not a supported stylesheet MIME type, and strict MIME checking is enabled.`
This error occurs when the browser requests static layout stylesheets from paths that do not exist, causing the back-end host to return a default 404 HTML fallback page. To resolve this, run the installation command in your client-side workspace :
```bash
abp install-libs
```
This command forces the ABP CLI to parse package dependencies, copy the compiled stylesheets directly into the physical output directories, and make them available to the web server.
### Deep Implementation: Integrating Theme Source Code and the Upgrade Trade-Off
For complex enterprise scenarios requiring structural changes that cannot be achieved via standard token configurations or component replacements, developers have the option to bypass compiled packages entirely and integrate the theme’s raw source code.
### How to Retrieve the Source Code
ABP Commercial customers have full access to the complete source code of the LeptonX Pro theme. This can be downloaded directly through the ABP Suite user interface or by executing the following command in the ABP CLI within your project directory :
```
abp get-source Volo.Abp.LeptonXTheme
```
This command downloads the raw C# and Angular source files directly into your local solution structure. Once downloaded, you can modify the underlying HTML templates, restructure Angular modules, and alter core layout scripts to meet your product requirements.
#### The Upgrade Warning: Maintenance Overhead and Style Debt
While direct access to the source code provides complete design freedom, it comes with a major warning regarding long-term maintenance :
- **Bypassing the Update Stream:** Once you replace official package references (such as `@volosoft/abp.ng.theme.lepton-x` or NuGet packages) with local project references, your application is disconnected from the automatic update pipeline.
- **Manual Merge Burden:** When Volosoft releases framework updates, security patches, or compatibility fixes (such as aligning with newer Angular or.NET compiler baselines), these updates will not automatically apply to your customized code. Your team must manually compare, diff, and merge upstream changes, which can introduce regressions and increase technical debt.
- **VFS and APIs as the First Line of Defense:** Before choosing a full source code integration, try using the Virtual File System (VFS) on the backend or standard component replacement APIs in the frontend to override only the specific elements you need to change. This allows you to customize the UI while keeping the rest of your theme packages fully upgradeable.
### Connecting the Mindset to ABP’s New React Era
The introduction of the React UI option in ABP 10.4 represents a major architectural shift. While the Angular implementation relies on structured layout packages and runtime component overrides, the React architecture prioritizes **direct developer ownership** of the presentation layer.
```mermaid
graph TD
%% Styling
classDef react fill:#e3f2fd,stroke:#1e88e5,stroke-width:2px,color:#0d47a1;
classDef dotnet fill:#f3e5f5,stroke:#8e24aa,stroke-width:2px,color:#4a148c;
classDef proxy fill:#fff3e0,stroke:#fb8c00,stroke-width:2px,color:#e65100;
classDef tool fill:#f5f5f5,stroke:#757575,color:#333;
%% React App Box
subgraph ReactApp ["React App Repository"]
C1["Custom Business Components<br><small>(Local Source Code)</small>"]:::react
C2["TanStack Router & Query<br><small>(Type-Safe Client Routes)</small>"]:::react
T1["Vite Dev Server & Bundling<br><small>(Fast HMR, Vitest)</small>"]:::tool
T2["Tailwind CSS / shadcn/ui<br><small>(Accessible UI Components)</small>"]:::tool
C1 --> C2
T1 --> T2
end
%% Backend Box
subgraph NetCore ["ASP.NET Core Web API Host"]
P1["Dynamic API Client Proxies<br><small>(Auto-Generated Endpoints)</small>"]:::proxy
A1["ABP Admin Console<br><small>(Delivered via NuGet)</small>"]:::dotnet
P1 <==> A1
end
%% Inter-Repository Flow
ReactApp -- "Generates Dynamic Proxies" --> P1
%% Layout Tweaks
style ReactApp fill:#fafafa,stroke:#1e88e5,stroke-width:1px,stroke-dasharray: 5 5;
style NetCore fill:#fafafa,stroke:#8e24aa,stroke-width:1px,stroke-dasharray: 5 5;
```
### What Stays Consistent vs. What Changes
Understanding how patterns transfer between frameworks is key for teams migrating to the React UI:
- **What Stays Consistent:** Core DDD infrastructure, backend integration, dynamic API proxy generation, multi-tenancy models, and permission-aware routing configurations.
- **What Changes:** Direct ownership of page layouts, faster iteration of UI composition, and modern utility-first styling tools.
### A New Frontend Philosophy
In the Angular model, developers import pre-built layouts from compiled packages and selectively override elements using classes or replacing components. While structured, this approach can sometimes feel like "fighting" the framework.
The React UI model, by contrast, gives developers direct control over the UI components from day one. Standard administrative pages (such as Identity, Tenants, and Settings) are managed separately by the **ABP Admin Console** on the back-end host, while all application layouts and views remain locally in your React project.
Built with modern tools like **Vite**, **Tailwind CSS**, and **shadcn/ui**, developers can customize and extend components directly in their local source files without needing complex overriding wrappers.
Additionally, because the layout and page templates reside in local source directories rather than compiled packages, this architecture is highly optimized for AI-driven development. Automated coding agents (such as the ABP Studio AI Agent) can easily inspect and modify local layouts, run API proxy generation, and deploy updates quickly.
Whether your enterprise solution leverages the structured, component-driven architecture of ABP's Angular UI or is stepping into the modern, developer-owned era of the Vite-powered React UI , establishing an intentional, upgrade-safe customization strategy is crucial. By resolving design changes through token-level custom properties first, documenting structural element overrides, and preparing public-facing technical resources to be highly citable by conversational search agents , development teams can insulate their codebases from technical debt. Ultimately, the transition from rigid theme packages to direct frontend ownership not only streamlines day-to-day software delivery but also ensures that your application framework remains flexible, performant, and visible in an AI-driven ecosystem.

6
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs

@ -42,14 +42,14 @@ public class AbpDateTimeModelBinder : IModelBinder
_clock.SupportsMultipleTimezone &&
!_currentTimezoneProvider.TimeZone.IsNullOrWhiteSpace())
{
var timeZone = _currentTimezoneProvider.TimeZone;
try
{
var timezoneInfo = _timezoneProvider.GetTimeZoneInfo(_currentTimezoneProvider.TimeZone);
dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime;
dateTime = _timezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone);
}
catch
{
_logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", _currentTimezoneProvider.TimeZone);
_logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone);
}
}

6
framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs

@ -134,14 +134,14 @@ public class AbpDateTimeConverter : DateTimeConverterBase, ITransientDependency
return _skipDateTimeNormalization ? dateTime : _clock.Normalize(dateTime);
}
var timeZone = _currentTimezoneProvider.TimeZone;
try
{
var timezoneInfo = _timezoneProvider.GetTimeZoneInfo(_currentTimezoneProvider.TimeZone);
dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime;
dateTime = _timezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone);
}
catch
{
Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", _currentTimezoneProvider.TimeZone);
Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone);
}
return _skipDateTimeNormalization

6
framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs

@ -101,14 +101,14 @@ public abstract class AbpDateTimeConverterBase<T> : JsonConverter<T>
return IsSkipDateTimeNormalization ? dateTime : Clock.Normalize(dateTime);
}
var timeZone = CurrentTimezoneProvider.TimeZone;
try
{
var timezoneInfo = TimezoneProvider.GetTimeZoneInfo(CurrentTimezoneProvider.TimeZone);
dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime;
dateTime = TimezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone);
}
catch
{
Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", CurrentTimezoneProvider.TimeZone);
Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone);
}
return IsSkipDateTimeNormalization ? dateTime : Clock.Normalize(dateTime);

37
framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs

@ -0,0 +1,37 @@
using System;
namespace Volo.Abp.Timing;
public static class TimezoneProviderExtensions
{
/// <summary>
/// Interprets <paramref name="dateTime"/> as local time in <paramref name="windowsOrIanaTimeZoneId"/>
/// and converts it to its UTC equivalent. The caller is expected to pass a
/// <see cref="DateTimeKind.Unspecified"/> value; the kind is not inspected, so a value is always
/// treated as wall-clock time in the given timezone regardless of its kind.
/// </summary>
/// <remarks>
/// Returns <paramref name="dateTime"/> unchanged when applying the timezone offset would move it
/// outside the supported <see cref="DateTime"/> range. This happens for values within the offset
/// distance of <see cref="DateTime.MinValue"/>/<see cref="DateTime.MaxValue"/>, typically the
/// <see cref="DateTime.MinValue"/> placeholder that does not represent a real instant. Computing the
/// UTC ticks directly avoids the <see cref="ArgumentOutOfRangeException"/> that
/// <c>new DateTimeOffset(dateTime, offset)</c> would throw for such values.
/// </remarks>
public static DateTime ConvertUnspecifiedToUtc(
this ITimezoneProvider timezoneProvider,
DateTime dateTime,
string windowsOrIanaTimeZoneId)
{
Check.NotNull(timezoneProvider, nameof(timezoneProvider));
var timezoneInfo = timezoneProvider.GetTimeZoneInfo(windowsOrIanaTimeZoneId);
var utcTicks = dateTime.Ticks - timezoneInfo.GetUtcOffset(dateTime).Ticks;
if (utcTicks < DateTime.MinValue.Ticks || utcTicks > DateTime.MaxValue.Ticks)
{
return dateTime;
}
return new DateTime(utcTicks, DateTimeKind.Utc);
}
}

200
framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs

@ -0,0 +1,200 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Shouldly;
using Volo.Abp.Timing;
using Xunit;
namespace Volo.Abp.Json;
/// <summary>
/// Regression tests for the warning
/// "Could not convert DateTime with unspecified Kind using timezone '...'."
/// logged by <c>AbpDateTimeConverterBase.Normalize</c>.
///
/// When <see cref="AbpClockOptions.Kind"/> is <see cref="DateTimeKind.Utc"/> the converter treats
/// an <see cref="DateTimeKind.Unspecified"/> value as local time in the current user's timezone and
/// converts it to UTC. For a value within the offset distance of <see cref="DateTime.MinValue"/>/
/// <see cref="DateTime.MaxValue"/> (most commonly the <see cref="DateTime.MinValue"/> placeholder) a
/// non-zero offset pushes the UTC equivalent outside the supported range, which used to be swallowed
/// and logged as a warning on every serialization. The converter now detects this boundary case and
/// keeps the value unchanged, so no warning is emitted while the serialized output stays the same.
/// </summary>
public class AbpDateTimeConverterTimezone_Tests : AbpJsonSystemTextJsonTestBase
{
private const string WarningFragment = "Could not convert DateTime with unspecified Kind";
private static readonly CapturingLoggerProvider LogCapture = new();
private readonly IJsonSerializer _jsonSerializer;
private readonly ICurrentTimezoneProvider _currentTimezoneProvider;
public AbpDateTimeConverterTimezone_Tests()
{
_jsonSerializer = GetRequiredService<IJsonSerializer>();
_currentTimezoneProvider = GetRequiredService<ICurrentTimezoneProvider>();
}
protected override void AfterAddApplication(IServiceCollection services)
{
// The warning only happens with a UTC clock, where Unspecified values get converted to UTC.
services.Configure<AbpClockOptions>(options => options.Kind = DateTimeKind.Utc);
LogCapture.Clear();
services.AddSingleton<ILoggerProvider>(LogCapture);
base.AfterAddApplication(services);
}
private sealed class FileModel
{
public DateTime DateModified { get; set; }
public DateTime DateCreated { get; set; }
}
[Theory]
[InlineData("Asia/Shanghai")] // +08:00
[InlineData("Europe/Brussels")] // +01:00 / +02:00
public void Should_Not_Warn_When_Serializing_MinValue_Under_Positive_Offset_Timezone(string timeZoneId)
{
_currentTimezoneProvider.TimeZone = timeZoneId;
LogCapture.Clear();
DateTime.MinValue.Kind.ShouldBe(DateTimeKind.Unspecified);
var json = _jsonSerializer.Serialize(new FileModel
{
DateModified = DateTime.MinValue,
DateCreated = DateTime.MinValue
});
// The placeholder is serialized unchanged and no warning is logged.
json.ShouldContain("0001-01-01");
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
[Fact]
public void Should_Not_Warn_When_Serializing_Value_Near_MinValue_Under_Positive_Offset_Timezone()
{
// Not exactly DateTime.MinValue: any value within the offset distance of the lower bound
// overflows the same way, so the converter must absorb it rather than warn.
_currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00
LogCapture.Clear();
var nearMin = DateTime.MinValue.AddHours(3); // 0001-01-01T03:00:00 - 08:00 underflows
_jsonSerializer.Serialize(new FileModel
{
DateModified = nearMin,
DateCreated = nearMin
});
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
[Fact]
public void Should_Not_Warn_When_Serializing_MaxValue_Under_Negative_Offset_Timezone()
{
// The symmetric case: a negative offset would push MaxValue past DateTime.MaxValue.
_currentTimezoneProvider.TimeZone = "America/New_York";
LogCapture.Clear();
var json = _jsonSerializer.Serialize(new FileModel
{
DateModified = DateTime.MaxValue,
DateCreated = DateTime.MaxValue
});
json.ShouldContain("9999-12-31");
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
[Fact]
public void Should_Not_Warn_When_Serializing_Real_Utc_Timestamp_Under_Positive_Offset_Timezone()
{
_currentTimezoneProvider.TimeZone = "Asia/Shanghai";
LogCapture.Clear();
var utc = new DateTime(2026, 6, 27, 10, 28, 7, DateTimeKind.Utc);
var json = _jsonSerializer.Serialize(new FileModel
{
DateModified = utc,
DateCreated = utc
});
json.ShouldContain("2026-06-27T10:28:07Z");
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
[Fact]
public void Should_Still_Convert_Real_Unspecified_Timestamp_To_Utc_Under_Positive_Offset_Timezone()
{
// A genuine (non-sentinel) Unspecified value must still be converted to UTC using the offset.
_currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00
LogCapture.Clear();
var unspecified = new DateTime(2026, 6, 27, 18, 0, 0, DateTimeKind.Unspecified);
var json = _jsonSerializer.Serialize(new FileModel
{
DateModified = unspecified,
DateCreated = unspecified
});
// 18:00 in +08:00 == 10:00 UTC.
json.ShouldContain("2026-06-27T10:00:00Z");
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
private sealed class CapturingLoggerProvider : ILoggerProvider
{
public ConcurrentQueue<string> Warnings { get; } = new();
public void Clear()
{
Warnings.Clear();
}
public ILogger CreateLogger(string categoryName)
{
return new CapturingLogger(Warnings);
}
public void Dispose()
{
}
private sealed class CapturingLogger : ILogger
{
private readonly ConcurrentQueue<string> _sink;
public CapturingLogger(ConcurrentQueue<string> sink)
{
_sink = sink;
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return logLevel >= LogLevel.Warning;
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
Func<TState, Exception, string> formatter)
{
if (logLevel >= LogLevel.Warning)
{
_sink.Enqueue(formatter(state, exception));
}
}
}
}
}

60
framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs

@ -0,0 +1,60 @@
using System;
using Shouldly;
using Volo.Abp.Testing;
using Xunit;
namespace Volo.Abp.Timing;
public class TimezoneProviderExtensions_Tests : AbpIntegratedTest<AbpTimingTestModule>
{
private readonly ITimezoneProvider _timezoneProvider;
public TimezoneProviderExtensions_Tests()
{
_timezoneProvider = GetRequiredService<ITimezoneProvider>();
}
[Theory]
[InlineData("Asia/Shanghai")] // +08:00
[InlineData("Europe/Brussels")] // +01:00 / +02:00
public void Should_Keep_MinValue_Unchanged_Under_Positive_Offset(string timeZoneId)
{
// A positive offset would push DateTime.MinValue below the supported range; keep it as-is.
var result = _timezoneProvider.ConvertUnspecifiedToUtc(DateTime.MinValue, timeZoneId);
result.ShouldBe(DateTime.MinValue);
result.Kind.ShouldBe(DateTimeKind.Unspecified);
}
[Fact]
public void Should_Keep_Value_Near_MinValue_Unchanged_Under_Positive_Offset()
{
var nearMin = DateTime.MinValue.AddHours(3); // 0001-01-01T03:00:00 - 08:00 underflows
var result = _timezoneProvider.ConvertUnspecifiedToUtc(nearMin, "Asia/Shanghai");
result.ShouldBe(nearMin);
result.Kind.ShouldBe(DateTimeKind.Unspecified);
}
[Fact]
public void Should_Keep_MaxValue_Unchanged_Under_Negative_Offset()
{
var result = _timezoneProvider.ConvertUnspecifiedToUtc(DateTime.MaxValue, "America/New_York");
result.ShouldBe(DateTime.MaxValue);
result.Kind.ShouldBe(DateTimeKind.Unspecified);
}
[Fact]
public void Should_Convert_Unspecified_Value_To_Utc_Using_Offset()
{
var unspecified = new DateTime(2026, 6, 27, 18, 0, 0, DateTimeKind.Unspecified);
var result = _timezoneProvider.ConvertUnspecifiedToUtc(unspecified, "Asia/Shanghai"); // +08:00
// 18:00 in +08:00 == 10:00 UTC.
result.ShouldBe(new DateTime(2026, 6, 27, 10, 0, 0, DateTimeKind.Utc));
result.Kind.ShouldBe(DateTimeKind.Utc);
}
}

16
modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs

@ -173,7 +173,7 @@ namespace Volo.Docs.GitHub.Documents
{
if (commits == null)
{
return DateTime.MinValue;
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
var gitHubCommit = isFirstCommit ?
@ -182,20 +182,20 @@ namespace Volo.Docs.GitHub.Documents
if (gitHubCommit == null)
{
return DateTime.MinValue;
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
if (gitHubCommit.Commit == null)
{
return DateTime.MinValue;
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
if (gitHubCommit.Commit.Author == null)
{
return DateTime.MinValue;
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
return gitHubCommit.Commit.Author.Date.DateTime;
return gitHubCommit.Commit.Author.Date.UtcDateTime;
}
private async Task<IReadOnlyList<GitHubCommit>> GetGitHubCommitsOrNull(Project project, string documentName, string languageCode, string version)
@ -230,8 +230,8 @@ namespace Volo.Docs.GitHub.Documents
var fileCommitsAfterCreation = commits.Take(commits.Count - 1);
var commitsToEvaluate = (lastKnownSignificantUpdateTime != null
? fileCommitsAfterCreation.Where(c => c.Commit.Author.Date.DateTime > lastKnownSignificantUpdateTime)
: fileCommitsAfterCreation).Where(c => c.Commit.Author.Date.DateTime > DateTime.Now.AddDays(-14));
? fileCommitsAfterCreation.Where(c => c.Commit.Author.Date.UtcDateTime > lastKnownSignificantUpdateTime)
: fileCommitsAfterCreation).Where(c => c.Commit.Author.Date.UtcDateTime > DateTime.UtcNow.AddDays(-14));
foreach (var gitHubCommit in commitsToEvaluate)
{
@ -243,7 +243,7 @@ namespace Volo.Docs.GitHub.Documents
if (_githubPatchAnalyzer.HasPatchSignificantChanges(fullCommit.Files.First(f => f.Filename == fileName).Patch))
{
return gitHubCommit.Commit.Author.Date.DateTime;
return gitHubCommit.Commit.Author.Date.UtcDateTime;
}
}

Loading…
Cancel
Save