diff --git a/core-web/libs/dotcms-js/src/lib/core/login.service.ts b/core-web/libs/dotcms-js/src/lib/core/login.service.ts index 4c9dc668c0a9..cbd583982de8 100644 --- a/core-web/libs/dotcms-js/src/lib/core/login.service.ts +++ b/core-web/libs/dotcms-js/src/lib/core/login.service.ts @@ -25,8 +25,11 @@ export const LOGOUT_URL = '/dotAdmin/logout'; * This Service get the server configuration to display in the login component * and execute the login and forgot password routines */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class LoginService { + currentUserLanguageId = ''; private country = ''; private lang = ''; private urls: Record; @@ -327,6 +330,8 @@ export class LoginService { this._auth = this.getFullAuth(auth); this._auth$.next(this.getFullAuth(auth)); + this.currentUserLanguageId = auth.user.languageId; + // When not logged user we need to fire the observable chain if (!auth.user) { this._logout$.next(); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html index 270847ea596f..3ece6273704c 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html @@ -19,26 +19,24 @@ {{ experiment.name }} - {{ experiment.creationDate | dotRelativeDate }} + {{ experiment.creationDate | dotTimestampToDate }} - {{ experiment.modDate | dotRelativeDate }} + {{ experiment.modDate | dotTimestampToDate }} + appendTo="body"> + type="button"> diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.spec.ts index ec1cb835a2b6..2f0de09566d3 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.spec.ts @@ -8,8 +8,13 @@ import { Menu, MenuItemContent } from 'primeng/menu'; import { Table } from 'primeng/table'; import { DotMessageService } from '@dotcms/data-access'; +import { LoginService } from '@dotcms/dotcms-js'; import { DotExperimentStatus, GroupedExperimentByStatus } from '@dotcms/dotcms-models'; -import { DotEmptyContainerComponent, DotFormatDateService } from '@dotcms/ui'; +import { + DotEmptyContainerComponent, + DotFormatDateService, + DotTimestampToDatePipe +} from '@dotcms/ui'; import { DotFormatDateServiceMock, getExperimentMock, @@ -77,6 +82,7 @@ describe('DotExperimentsListTableComponent', () => { const createComponent = createComponentFactory({ component: DotExperimentsListTableComponent, + imports: [DotTimestampToDatePipe], componentMocks: [ConfirmPopup], declarations: [MockDatePipe], providers: [ @@ -86,7 +92,11 @@ describe('DotExperimentsListTableComponent', () => { }, MessageService, ConfirmationService, - { provide: DotFormatDateService, useClass: DotFormatDateServiceMock } + { provide: DotFormatDateService, useClass: DotFormatDateServiceMock }, + { + provide: LoginService, + useValue: { currentUserLanguageId: 'en-US' } + } ], schemas: [NO_ERRORS_SCHEMA] }); @@ -134,9 +144,11 @@ describe('DotExperimentsListTableComponent', () => { DRAFT_EXPERIMENT_MOCK.name ); expect(spectator.query(byTestId('experiment-row__createdDate'))).toHaveText( - '1 hour ago' + '10/10/2020 10:10 PM' + ); + expect(spectator.query(byTestId('experiment-row__modDate'))).toHaveText( + '10/10/2020 10:10 PM' ); - expect(spectator.query(byTestId('experiment-row__modDate'))).toHaveText('1 hour ago'); }); it('should emit action when a row is clicked', () => { diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.ts index 053b092867cc..016628f8712c 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.ts @@ -21,6 +21,7 @@ import { DotEmptyContainerComponent, DotMessagePipe, DotRelativeDatePipe, + DotTimestampToDatePipe, PrincipalConfiguration } from '@dotcms/ui'; @@ -41,7 +42,8 @@ import { ButtonModule, TooltipModule, MenuModule, - DotEmptyContainerComponent + DotEmptyContainerComponent, + DotTimestampToDatePipe ], templateUrl: './dot-experiments-list-table.component.html', styleUrls: ['./dot-experiments-list-table.component.scss'], @@ -50,8 +52,10 @@ import { }) export class DotExperimentsListTableComponent { @Input() experimentGroupedByStatus: GroupedExperimentByStatus[] = []; + @Output() goToContainer = new EventEmitter(); + private dotMessageService: DotMessageService = inject(DotMessageService); protected readonly emptyConfiguration: PrincipalConfiguration = { title: this.dotMessageService.get('experimentspage.not.experiments.found.filtered'), diff --git a/core-web/libs/ui/src/index.ts b/core-web/libs/ui/src/index.ts index a61351dda426..b411d37918e5 100644 --- a/core-web/libs/ui/src/index.ts +++ b/core-web/libs/ui/src/index.ts @@ -24,3 +24,4 @@ export * from './lib/services/clipboard/ClipboardUtil'; export * from './lib/pipes/dot-relative-date/dot-relative-date.pipe'; export * from './lib/dot-message/dot-message.pipe'; export * from './lib/pipes/dot-string-format/dot-string-format.pipe'; +export * from './lib/pipes/dot-timestamp-to-date/dot-timestamp-to-date.pipe'; diff --git a/core-web/libs/ui/src/lib/pipes/dot-relative-date/dot-relative-date.pipe.spec.ts b/core-web/libs/ui/src/lib/pipes/dot-relative-date/dot-relative-date.pipe.spec.ts index 8b5222cf7816..ad2b45ac8150 100644 --- a/core-web/libs/ui/src/lib/pipes/dot-relative-date/dot-relative-date.pipe.spec.ts +++ b/core-web/libs/ui/src/lib/pipes/dot-relative-date/dot-relative-date.pipe.spec.ts @@ -1,6 +1,6 @@ -import { fakeAsync, tick } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { DotcmsConfigService } from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoginService } from '@dotcms/dotcms-js'; import { DotFormatDateService, DotRelativeDatePipe } from '@dotcms/ui'; import { DotcmsConfigServiceMock } from '@dotcms/utils-testing'; @@ -18,11 +18,23 @@ describe('DotRelativeDatePipe', () => { let pipe: DotRelativeDatePipe; beforeEach(() => { - formatDateService = new DotFormatDateService( - new DotcmsConfigServiceMock() as unknown as DotcmsConfigService - ); - - pipe = new DotRelativeDatePipe(formatDateService as unknown as DotFormatDateService); + TestBed.configureTestingModule({ + providers: [ + { + provide: LoginService, + useValue: { currentUserLanguageId: 'en-US' } + }, + { + provide: DotcmsConfigService, + useClass: DotcmsConfigServiceMock + }, + DotFormatDateService, + DotRelativeDatePipe + ] + }); + + formatDateService = TestBed.inject(DotFormatDateService); + pipe = TestBed.inject(DotRelativeDatePipe); }); describe('relative', () => { diff --git a/core-web/libs/ui/src/lib/pipes/dot-timestamp-to-date/dot-timestamp-to-date.pipe.spec.ts b/core-web/libs/ui/src/lib/pipes/dot-timestamp-to-date/dot-timestamp-to-date.pipe.spec.ts new file mode 100644 index 000000000000..967db6bb0503 --- /dev/null +++ b/core-web/libs/ui/src/lib/pipes/dot-timestamp-to-date/dot-timestamp-to-date.pipe.spec.ts @@ -0,0 +1,29 @@ +import { createPipeFactory, mockProvider, SpectatorPipe } from '@ngneat/spectator'; + +import { DotFormatDateService } from '@dotcms/ui'; + +import { DotTimestampToDatePipe } from './dot-timestamp-to-date.pipe'; + +const TIMESTAMP_MOCK = 1698789866; +const EXPECTED_DATE_MOCK = '10/29/2023, 12:43 PM'; +describe('DotTimestampPipe ', () => { + let spectator: SpectatorPipe; + + const createPipe = createPipeFactory({ + pipe: DotTimestampToDatePipe, + providers: [ + DotFormatDateService, + mockProvider(DotFormatDateService, { getDateFromTimestamp: () => EXPECTED_DATE_MOCK }) + ] + }); + + it('should transform the timestamp using getDateFromTimestamp to date format', () => { + spectator = createPipe(`
{{ timestamp | dotTimestampToDate }}
`, { + hostProps: { + TIMESTAMP_MOCK + } + }); + + expect(spectator.element).toHaveText(EXPECTED_DATE_MOCK); + }); +}); diff --git a/core-web/libs/ui/src/lib/pipes/dot-timestamp-to-date/dot-timestamp-to-date.pipe.ts b/core-web/libs/ui/src/lib/pipes/dot-timestamp-to-date/dot-timestamp-to-date.pipe.ts new file mode 100644 index 000000000000..bd512655ad93 --- /dev/null +++ b/core-web/libs/ui/src/lib/pipes/dot-timestamp-to-date/dot-timestamp-to-date.pipe.ts @@ -0,0 +1,30 @@ +import { inject, Pipe, PipeTransform } from '@angular/core'; + +import { DotFormatDateService } from '@dotcms/ui'; + +/** + * Transforms a timestamp into a formatted date string based on the user's selected language at login + * + * @remarks + * This pipe is a pure pipe, meaning it is only re-evaluated when the input value changes. + * + * @example + * ```html + *

{{ timestampValue | dotTimestampToDate }}

+ * ``` + * + * @param time - The timestamp to be transformed into a date string. + * @param userDateFormatOptions - Optional. The formatting options for the date string. + * @returns A formatted date string based on the provided timestamp and formatting options. + */ +@Pipe({ + name: 'dotTimestampToDate', + standalone: true, + pure: true +}) +export class DotTimestampToDatePipe implements PipeTransform { + private dotFormatDateService: DotFormatDateService = inject(DotFormatDateService); + transform(time: number, userDateFormatOptions?: Intl.DateTimeFormatOptions): string { + return this.dotFormatDateService.getDateFromTimestamp(time, userDateFormatOptions); + } +} diff --git a/core-web/libs/ui/src/lib/services/dot-format-date-service.spec.ts b/core-web/libs/ui/src/lib/services/dot-format-date-service.spec.ts new file mode 100644 index 000000000000..5c18de8f0563 --- /dev/null +++ b/core-web/libs/ui/src/lib/services/dot-format-date-service.spec.ts @@ -0,0 +1,62 @@ +import { createServiceFactory, mockProvider, SpectatorService, SpyObject } from '@ngneat/spectator'; +import { of } from 'rxjs'; + +import { DotcmsConfigService, LoginService } from '@dotcms/dotcms-js'; +import { DotFormatDateService } from '@dotcms/ui'; + +const INVALID_DATE_MSG = 'Invalid date'; +const VALID_TIMESTAMP = 1701189800000; +const WRONG_TIMESTAMP = 1651337877000000; +const DateFormatOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: true, + timeZone: 'UTC' +}; + +describe('DotFormatDateService', () => { + let spectator: SpectatorService; + let loginService: SpyObject; + const createService = createServiceFactory({ + service: DotFormatDateService, + providers: [ + mockProvider(DotcmsConfigService, { + getSystemTimeZone: () => of('idk') + }), + mockProvider(LoginService) + ] + }); + + beforeEach(() => { + spectator = createService(); + loginService = spectator.inject(LoginService); + loginService.currentUserLanguageId = 'en-US'; + }); + + describe('getDateFromTimestamp', () => { + it('should return `Invalid date` when is not a timestamp', () => { + expect(spectator.service.getDateFromTimestamp(WRONG_TIMESTAMP)).toContain( + INVALID_DATE_MSG + ); + }); + + it('should return a string date using timestamp using `currentUserLanguageId`with us-US', () => { + const EXPECTED_DATE = '11/28/2023, 04:43 PM'; + expect(spectator.service.getDateFromTimestamp(VALID_TIMESTAMP, DateFormatOptions)).toBe( + EXPECTED_DATE + ); + }); + + it('should return a string with correct date format using timestamp using `currentUserLanguageId` with es-ES', () => { + const EXPECTED_DATE = '28/11/2023, 04:43 p. m.'; + + loginService.currentUserLanguageId = 'es-ES'; + expect(spectator.service.getDateFromTimestamp(VALID_TIMESTAMP, DateFormatOptions)).toBe( + EXPECTED_DATE + ); + }); + }); +}); diff --git a/core-web/libs/ui/src/lib/services/dot-format-date-service.ts b/core-web/libs/ui/src/lib/services/dot-format-date-service.ts index 433bad7867c9..0514ce0716a4 100644 --- a/core-web/libs/ui/src/lib/services/dot-format-date-service.ts +++ b/core-web/libs/ui/src/lib/services/dot-format-date-service.ts @@ -1,11 +1,14 @@ import { differenceInCalendarDays, format, formatDistanceStrict, isValid, parse } from 'date-fns'; import { format as formatTZ, utcToZonedTime } from 'date-fns-tz'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; -import { DotcmsConfigService, DotTimeZone } from '@dotcms/dotcms-js'; +import { DotcmsConfigService, DotTimeZone, LoginService } from '@dotcms/dotcms-js'; import { DotLocaleOptions } from '@dotcms/dotcms-models'; +const DEFAULT_ISO_LOCALE = 'en-US'; +const INVALID_DATE_MSG = 'Invalid date'; + // Created outside of the service so it can be used on date.validator.ts export function _isValid(date: string, formatPattern: string) { return isValid(parse(date, formatPattern, new Date())); @@ -15,7 +18,17 @@ export function _isValid(date: string, formatPattern: string) { providedIn: 'root' }) export class DotFormatDateService { - private _localeOptions: DotLocaleOptions; + private loginService: LoginService = inject(LoginService); + + private defaultDateFormatOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: true + }; + private _systemTimeZone: DotTimeZone; constructor(dotcmsConfigService: DotcmsConfigService) { @@ -24,6 +37,8 @@ export class DotFormatDateService { .subscribe((timezone) => (this._systemTimeZone = timezone)); } + private _localeOptions: DotLocaleOptions; + get localeOptions(): DotLocaleOptions { return this._localeOptions; } @@ -32,6 +47,11 @@ export class DotFormatDateService { this._localeOptions = locale; } + /** + * @deprecated + * please do not use more date-fns use instead Intl.DateTimeFormat + * @param languageId + */ async setLang(languageId: string) { let [langCode, countryCode] = languageId.replace('_', '-').split('-'); let localeLang; @@ -52,6 +72,36 @@ export class DotFormatDateService { this.localeOptions = { locale: localeLang.default }; } + /** + * Transform a timestamp to a date string using Intl.DateTimeFormat + * taking in consideration the user's language selected when logged in. + * + * @param {number} timestamp + * @param userDateFormatOptions + * @returns {Date} + */ + getDateFromTimestamp( + timestamp: number, + userDateFormatOptions?: Intl.DateTimeFormatOptions + ): string { + if (!this.isValidTimestamp(timestamp)) { + console.error('Invalid timestamp provided:', timestamp); + + return INVALID_DATE_MSG; + } + + try { + const options = userDateFormatOptions || this.defaultDateFormatOptions; + const formatter = new Intl.DateTimeFormat(this.getLocaleISOSelectedAtLogin(), options); + + return formatter.format(new Date(timestamp)).replace(/\s+/g, ' ').trim(); // space normalization + } catch (error) { + console.error('Error formatting date:', error); + + return INVALID_DATE_MSG; + } + } + /** * Get the number of calendar days between the given dates. This means that the * times are removed from the dates and then the difference in days is calculated. @@ -127,8 +177,42 @@ export class DotFormatDateService { * @memberof DotFormatDateService */ getUTC(time: Date = new Date()): Date { - const utcTime = new Date(time.getTime() + time.getTimezoneOffset() * 60000); + return new Date(time.getTime() + time.getTimezoneOffset() * 60000); + } + + /** + * Checks if the given value is a valid timestamp. + * + * @param {number} value - The value to be checked. + * @return {boolean} - Returns true if the value is a valid timestamp, otherwise false. + */ + isValidTimestamp(value: number): boolean { + // Check if the value is of type number + if (typeof value !== 'number') { + return false; + } + + if (value < 0 || value > 1e13) { + // 1e13 covers up to the year 2286 + return false; + } + + // Attempt to construct a Date object with the timestamp + const date = new Date(value); + + // and NaN if it is not. `isNaN` checks if the value is NaN. + return !isNaN(date.getTime()); + } + + /** + * Converts the given locale string to the ISO 639-1 format. + * + * @return {string} - The converted locale string in the ISO 639-1 format. + * @private + */ + private getLocaleISOSelectedAtLogin(): string { + const languageLoggedIn = this.loginService.currentUserLanguageId; - return utcTime; + return languageLoggedIn ? languageLoggedIn.replace('_', '-') : DEFAULT_ISO_LOCALE; } } diff --git a/core-web/libs/utils-testing/src/lib/format-date-service.mock.ts b/core-web/libs/utils-testing/src/lib/format-date-service.mock.ts index b57fc98df248..57cf4ed9902e 100644 --- a/core-web/libs/utils-testing/src/lib/format-date-service.mock.ts +++ b/core-web/libs/utils-testing/src/lib/format-date-service.mock.ts @@ -37,4 +37,7 @@ export class DotFormatDateServiceMock { differenceInCalendarDays(_dateLeft: Date, _dateRight: Date): number { return 1; } + getDateFromTimestamp(_time: number, _userDateFormatOptions?: Intl.DateTimeFormatOptions) { + return '10/10/2020 10:10 PM'; + } } diff --git a/dotCMS/hotfix_tracking.md b/dotCMS/hotfix_tracking.md index 657c42992051..53f0f32fd29b 100644 --- a/dotCMS/hotfix_tracking.md +++ b/dotCMS/hotfix_tracking.md @@ -169,3 +169,4 @@ This maintenance release includes the following code fixes: 162. https://github.com/dotCMS/core/issues/30156 : Create Notifications for LTS already EOL or upcoming EOL #30156 163. https://github.com/dotCMS/core/issues/30243 : Intermittent 404 issues for customers who came from another DB engine #30243 164. https://github.com/dotCMS/core/issues/26271 : [UI] Text in experiment data results needs be aligned #26271 +165. https://github.com/dotCMS/core/issues/26399 : [UI] Change Experiment mod date display from simple date to date/time #26399