Browse Source

Merge pull request #22619 from abpframework/feature/#22507

Multi-timezone support in Angular
pull/22818/head
oykuermann 9 months ago
committed by GitHub
parent
commit
7d3e5ce241
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 27
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html
  2. 19
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts
  3. 3
      npm/ng-packs/packages/core/ng-package.json
  4. 3
      npm/ng-packs/packages/core/package.json
  5. 3
      npm/ng-packs/packages/core/src/lib/core.module.ts
  6. 1
      npm/ng-packs/packages/core/src/lib/interceptors/index.ts
  7. 26
      npm/ng-packs/packages/core/src/lib/interceptors/timezone.interceptor.ts
  8. 1
      npm/ng-packs/packages/core/src/lib/pipes/index.ts
  9. 50
      npm/ng-packs/packages/core/src/lib/pipes/utc-to-local.pipe.ts
  10. 7
      npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts
  11. 2
      npm/ng-packs/packages/core/src/lib/services/index.ts
  12. 107
      npm/ng-packs/packages/core/src/lib/services/time.service.ts
  13. 63
      npm/ng-packs/packages/core/src/lib/services/timezone.service.ts

27
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html

@ -49,18 +49,33 @@
<ng-container *abpPermission="prop.permission; runChangeDetection: false">
<ng-container *abpVisible="row['_' + prop.name]?.visible">
@if (!row['_' + prop.name].component) {
<div
[innerHTML]="
@if (prop.type === 'datetime' || prop.type === 'date' || prop.type === 'time') {
<div
[innerHTML]="
!prop.isExtra
? (row['_' + prop.name]?.value | async | abpUtcToLocal:prop.type)
: ('::' + (row['_' + prop.name]?.value | async | abpUtcToLocal:prop.type) | abpLocalization)
"
(click)="
prop.action && prop.action({ getInjected: getInjected, record: row, index: i })
"
[ngClass]="entityPropTypeClasses[prop.type]"
[class.pointer]="prop.action"
></div>
} @else {
<div
[innerHTML]="
!prop.isExtra
? (row['_' + prop.name]?.value | async)
: ('::' + (row['_' + prop.name]?.value | async) | abpLocalization)
"
(click)="
(click)="
prop.action && prop.action({ getInjected: getInjected, record: row, index: i })
"
[ngClass]="entityPropTypeClasses[prop.type]"
[class.pointer]="prop.action"
></div>
[ngClass]="entityPropTypeClasses[prop.type]"
[class.pointer]="prop.action"
></div>
}
} @else {
<ng-container
*ngComponentOutlet="

19
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts

@ -14,7 +14,7 @@ import {
TemplateRef,
TrackByFunction,
} from '@angular/core';
import { AsyncPipe, formatDate, NgComponentOutlet, NgTemplateOutlet } from '@angular/common';
import { AsyncPipe, NgComponentOutlet, NgTemplateOutlet } from '@angular/common';
import { Observable, filter, map } from 'rxjs';
@ -24,13 +24,12 @@ import { NgxDatatableModule } from '@swimlane/ngx-datatable';
import {
ABP,
ConfigStateService,
getShortDateFormat,
getShortDateShortTimeFormat,
getShortTimeFormat,
ListService,
LocalizationModule,
PermissionDirective,
PermissionService,
TimezoneService,
UtcToLocalPipe,
} from '@abp/ng.core';
import {
AbpVisibleDirective,
@ -64,6 +63,7 @@ const DEFAULT_ACTIONS_COLUMN_WIDTH = 150;
NgxDatatableListDirective,
PermissionDirective,
LocalizationModule,
UtcToLocalPipe,
AsyncPipe,
NgTemplateOutlet,
NgComponentOutlet,
@ -77,6 +77,7 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
protected readonly cdr = inject(ChangeDetectorRef);
protected readonly locale = inject(LOCALE_ID);
protected readonly config = inject(ConfigStateService);
protected readonly timeZoneService = inject(TimezoneService);
protected readonly entityPropTypeClasses = inject(ENTITY_PROP_TYPE_CLASSES);
protected readonly permissionService = inject(PermissionService);
@ -134,10 +135,6 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
(this.columnWidths as any) = widths;
}
private getDate(value: Date | undefined, format: string | undefined) {
return value && format ? formatDate(value, format, this.locale) : '';
}
private getIcon(value: boolean) {
return value
? '<div class="text-success"><i class="fa fa-check" aria-hidden="true"></i></div>'
@ -156,12 +153,6 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
switch (prop.type) {
case ePropType.Boolean:
return this.getIcon(value);
case ePropType.Date:
return this.getDate(value, getShortDateFormat(this.config));
case ePropType.Time:
return this.getDate(value, getShortTimeFormat(this.config));
case ePropType.DateTime:
return this.getDate(value, getShortDateShortTimeFormat(this.config));
case ePropType.Enum:
return this.getEnum(value, prop.enumList || []);
default:

3
npm/ng-packs/packages/core/ng-package.json

@ -9,6 +9,7 @@
"angular-oauth2-oidc",
"just-compare",
"just-clone",
"ts-toolbelt"
"ts-toolbelt",
"luxon"
]
}

3
npm/ng-packs/packages/core/package.json

@ -11,7 +11,8 @@
"just-clone": "^6.0.0",
"just-compare": "^2.0.0",
"ts-toolbelt": "^9.0.0",
"tslib": "^2.0.0"
"tslib": "^2.0.0",
"luxon": "^3.0.0"
},
"publishConfig": {
"access": "public"

3
npm/ng-packs/packages/core/src/lib/core.module.ts

@ -3,6 +3,7 @@ import {
provideHttpClient,
withInterceptorsFromDi,
withXsrfConfiguration,
HTTP_INTERCEPTORS,
} from '@angular/common/http';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@ -30,6 +31,7 @@ import { ShortTimePipe } from './pipes/short-time.pipe';
import { ShortDatePipe } from './pipes/short-date.pipe';
import { SafeHtmlPipe } from './pipes/safe-html.pipe';
import { provideAbpCoreChild, provideAbpCore, withOptions } from './providers';
import { UtcToLocalPipe } from './pipes';
const standaloneDirectives = [
AutofocusDirective,
@ -72,6 +74,7 @@ const standaloneDirectives = [
ReactiveFormsModule,
RouterModule,
LocalizationModule,
UtcToLocalPipe,
...standaloneDirectives,
],
declarations: [

1
npm/ng-packs/packages/core/src/lib/interceptors/index.ts

@ -1 +1,2 @@
export * from './api.interceptor';
export * from './timezone.interceptor';

26
npm/ng-packs/packages/core/src/lib/interceptors/timezone.interceptor.ts

@ -0,0 +1,26 @@
import { inject, Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { TimezoneService } from '../services';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class TimezoneInterceptor implements HttpInterceptor {
protected readonly timezoneService = inject(TimezoneService);
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (!this.timezoneService.isUtcClockEnabled) {
return next.handle(req);
}
const timezone = this.timezoneService.timezone;
if (timezone) {
req = req.clone({
setHeaders: {
__timezone: timezone,
},
});
}
return next.handle(req);
}
}

1
npm/ng-packs/packages/core/src/lib/pipes/index.ts

@ -5,3 +5,4 @@ export * from './to-injector.pipe';
export * from './short-date.pipe';
export * from './short-time.pipe';
export * from './short-date-time.pipe';
export * from './utc-to-local.pipe';

50
npm/ng-packs/packages/core/src/lib/pipes/utc-to-local.pipe.ts

@ -0,0 +1,50 @@
import { Pipe, PipeTransform, Injectable, inject, LOCALE_ID } from '@angular/core';
import { ConfigStateService, LocalizationService, TimeService, TimezoneService } from '../services';
import { getShortDateFormat, getShortDateShortTimeFormat, getShortTimeFormat } from '../utils';
@Injectable()
@Pipe({
name: 'abpUtcToLocal',
})
export class UtcToLocalPipe implements PipeTransform {
protected readonly timezoneService = inject(TimezoneService);
protected readonly timeService = inject(TimeService);
protected readonly configState = inject(ConfigStateService);
protected readonly localizationService = inject(LocalizationService);
protected readonly locale = inject(LOCALE_ID);
transform(
value: string | Date | null | undefined,
type: 'date' | 'datetime' | 'time',
): string | Date {
if (!value) return '';
const date = new Date(value);
if (isNaN(date.getTime())) return '';
const format = this.getFormat(type);
try {
if (this.timezoneService.isUtcClockEnabled) {
const timeZone = this.timezoneService.timezone;
return this.timeService.formatDateWithStandardOffset(date, format, timeZone);
} else {
return this.timeService.formatWithoutTimeZone(date, format);
}
} catch (err) {
return value;
}
}
private getFormat(propType: 'date' | 'datetime' | 'time'): string {
switch (propType) {
case 'date':
return getShortDateFormat(this.configState);
case 'time':
return getShortTimeFormat(this.configState);
case 'datetime':
default:
return getShortDateShortTimeFormat(this.configState);
}
}
}

7
npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts

@ -1,6 +1,7 @@
import { makeEnvironmentProviders, Provider, inject, provideAppInitializer } from '@angular/core';
import { TitleStrategy } from '@angular/router';
import {
HTTP_INTERCEPTORS,
provideHttpClient,
withInterceptorsFromDi,
withXsrfConfiguration,
@ -24,6 +25,7 @@ import { DEFAULT_DYNAMIC_LAYOUTS } from '../constants';
import { LocalizationService, LocalStorageListenerService, AbpTitleStrategy } from '../services';
import { DefaultQueueManager, getInitialData, localeInitializer } from '../utils';
import { CookieLanguageProvider, IncludeLocalizationResourcesProvider, LocaleProvider } from './';
import { TimezoneInterceptor } from '../interceptors';
export enum CoreFeatureKind {
Options,
@ -128,6 +130,11 @@ export function provideAbpCore(...features: CoreFeature<CoreFeatureKind>[]) {
provide: TitleStrategy,
useExisting: AbpTitleStrategy,
},
{
provide: HTTP_INTERCEPTORS,
useClass: TimezoneInterceptor,
multi: true,
},
];
for (const feature of features) {

2
npm/ng-packs/packages/core/src/lib/services/index.ts

@ -23,3 +23,5 @@ export * from './window.service';
export * from './internet-connection-service';
export * from './local-storage-listener.service';
export * from './title-strategy.service';
export * from './timezone.service';
export * from './time.service';

107
npm/ng-packs/packages/core/src/lib/services/time.service.ts

@ -0,0 +1,107 @@
import { inject, Injectable, LOCALE_ID } from '@angular/core';
import { DateTime } from 'luxon';
@Injectable({
providedIn: 'root',
})
export class TimeService {
private locale = inject(LOCALE_ID);
/**
* Returns the current date and time in the specified timezone.
*
* @param zone - An IANA timezone name (e.g., 'Europe/Istanbul', 'UTC'); defaults to the system's local timezone.
* @returns A Luxon DateTime instance representing the current time in the given timezone.
*/
now(zone = 'local'): DateTime {
return DateTime.now().setZone(zone);
}
/**
* Converts the input date to the specified timezone, applying any timezone and daylight saving time (DST) adjustments.
*
* This method:
* 1. Parses the input value into a Luxon DateTime object.
* 2. Applies the specified IANA timezone, including any DST shifts based on the given date.
*
* @param value - The ISO string or Date object to convert.
* @param zone - An IANA timezone name (e.g., 'America/New_York').
* @returns A Luxon DateTime instance adjusted to the specified timezone and DST rules.
*/
toZone(value: string | Date, zone: string): DateTime {
return DateTime.fromISO(value instanceof Date ? value.toISOString() : value, {
zone,
});
}
/**
* Formats the input date by applying timezone and daylight saving time (DST) adjustments.
*
* This method:
* 1. Converts the input date to the specified timezone.
* 2. Formats the result using the given format and locale, reflecting any timezone or DST shifts.
*
* @param value - The ISO string or Date object to format.
* @param format - The format string (default: 'ff').
* @param zone - Optional IANA timezone name (e.g., 'America/New_York'); defaults to the system's local timezone.
* @returns A formatted date string adjusted for the given timezone and DST rules.
*/
format(value: string | Date, format = 'ff', zone = 'local'): string {
return this.toZone(value, zone).setLocale(this.locale).toFormat(format);
}
/**
* Formats a date using the standard time offset (ignoring daylight saving time) for the specified timezone.
*
* This method:
* 1. Converts the input date to UTC.
* 2. Calculates the standard UTC offset for the given timezone (based on January 1st to avoid DST).
* 3. Applies the standard offset manually to the UTC time.
* 4. Formats the result using the specified format and locale, without applying additional timezone shifts.
*
* @param value - The ISO string or Date object to format.
* @param format - The Luxon format string (default: 'ff').
* @param zone - Optional IANA timezone name (e.g., 'America/New_York'); if omitted, system local timezone is used.
* @returns A formatted date string adjusted by standard time (non-DST).
*/
formatDateWithStandardOffset(value: string | Date, format = 'ff', zone?: string): string {
const utcDate =
typeof value === 'string'
? DateTime.fromISO(value, { zone: 'UTC' })
: DateTime.fromJSDate(value, { zone: 'UTC' });
if (!utcDate.isValid) return '';
const targetZone = zone ?? DateTime.local().zoneName;
const januaryDate = DateTime.fromObject(
{ year: utcDate.year, month: 1, day: 1 },
{ zone: targetZone },
);
const standardOffset = januaryDate.offset;
const dateWithStandardOffset = utcDate.plus({ minutes: standardOffset });
return dateWithStandardOffset.setZone('UTC').setLocale(this.locale).toFormat(format);
}
/**
* Formats the input date using its original clock time, without converting based on timezone or DST
*
* This method:
* 1. Converts the input date to ISO string.
* 2. Calculates the date time in UTC, keeping the local time.
* 3. Formats the result using the specified format and locale, without shifting timezones.
*
* @param value - The ISO string or Date object to format.
* @param format - The format string (default: 'ff').
* @returns A formatted date string without applying timezone.
*/
formatWithoutTimeZone(value: string | Date, format = 'ff'): string {
const isoString = value instanceof Date ? value.toISOString() : value;
const dateTime = DateTime.fromISO(isoString)
.setZone('utc', { keepLocalTime: true })
.setLocale(this.locale);
return dateTime.toFormat(format);
}
}

63
npm/ng-packs/packages/core/src/lib/services/timezone.service.ts

@ -0,0 +1,63 @@
import { inject, Injectable } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { ConfigStateService } from './config-state.service';
@Injectable({
providedIn: 'root',
})
export class TimezoneService {
protected readonly configState = inject(ConfigStateService);
protected readonly document = inject(DOCUMENT);
private readonly cookieKey = '__timezone';
private timeZoneNameFromSettings: string | null | undefined;
public isUtcClockEnabled: boolean | undefined;
constructor() {
this.configState.getOne$('setting').subscribe(settings => {
this.timeZoneNameFromSettings = settings?.values?.['Abp.Timing.TimeZone'];
});
this.configState.getOne$('clock').subscribe(clock => {
this.isUtcClockEnabled = clock?.kind === 'Utc';
});
}
/**
* Returns the effective timezone to be used across the application.
*
* This value is determined based on the clock kind setting in the configuration:
* - If clock kind is not equal to Utc, the browser's local timezone is returned.
* - If clock kind is equal to Utc, the configured timezone (`timeZoneNameFromSettings`) is returned if available;
* otherwise, the browser's timezone is used as a fallback.
*
* @returns The IANA timezone name (e.g., 'Europe/Istanbul', 'America/New_York').
*/
get timezone(): string {
if (!this.isUtcClockEnabled) {
return this.getBrowserTimezone();
}
return this.timeZoneNameFromSettings || this.getBrowserTimezone();
}
/**
* Retrieves the browser's local timezone based on the user's system settings.
*
* @returns The IANA timezone name (e.g., 'Europe/Istanbul', 'America/New_York').
*/
getBrowserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
/**
* Sets the application's timezone in a cookie to persist the user's selected timezone.
*
* This method sets the cookie only if the clock kind setting is set to UTC.
* The cookie is stored using the key defined by `this.cookieKey` and applied to the root path (`/`).
*
* @param timezone - The IANA timezone name to be stored (e.g., 'Europe/Istanbul').
*/
setTimezone(timezone: string): void {
if (this.isUtcClockEnabled) {
this.document.cookie = `${this.cookieKey}=${timezone}; path=/`;
}
}
}
Loading…
Cancel
Save