Skip to content

Commit

Permalink
NAS-132423 / 25.04 / Add ix-datepicker component (#11090)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: undsoft <[email protected]>
  • Loading branch information
denysbutenko and undsoft authored Nov 27, 2024
1 parent 9fe24d0 commit 485593d
Show file tree
Hide file tree
Showing 132 changed files with 669 additions and 158 deletions.
2 changes: 1 addition & 1 deletion src/app/core/testing/classes/fake-format-datetime.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { format } from 'date-fns-tz';
import { MockPipe } from 'ng-mocks';
import { FormatDateTimePipe } from 'app/modules/pipes/format-date-time/format-datetime.pipe';
import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe';

// eslint-disable-next-line @typescript-eslint/naming-convention
export const FakeFormatDateTimePipe = MockPipe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { AlertPageObject } from 'app/modules/alerts/components/alert/alert.page-
import { AlertEffects } from 'app/modules/alerts/store/alert.effects';
import { adapter, alertReducer, alertsInitialState } from 'app/modules/alerts/store/alert.reducer';
import { alertStateKey, selectAlerts } from 'app/modules/alerts/store/alert.selectors';
import { FormatDateTimePipe } from 'app/modules/pipes/format-date-time/format-datetime.pipe';
import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe';
import { ApiService } from 'app/services/websocket/api.service';
import { systemConfigReducer, SystemConfigState } from 'app/store/system-config/system-config.reducer';
import { systemConfigStateKey } from 'app/store/system-config/system-config.selectors';
Expand Down
2 changes: 1 addition & 1 deletion src/app/modules/alerts/components/alert/alert.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { AlertLevel, alertLevelLabels } from 'app/enums/alert-level.enum';
import { Role } from 'app/enums/role.enum';
import { Alert } from 'app/interfaces/alert.interface';
import { dismissAlertPressed, reopenAlertPressed } from 'app/modules/alerts/store/alert.actions';
import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe';
import { iconMarker } from 'app/modules/ix-icon/icon-marker.util';
import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component';
import { FormatDateTimePipe } from 'app/modules/pipes/format-date-time/format-datetime.pipe';
import { TestDirective } from 'app/modules/test-id/test.directive';
import { AppState } from 'app/store';
import { selectTimezone } from 'app/store/system-config/system-config.selectors';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createPipeFactory, SpectatorPipe } from '@ngneat/spectator/jest';
import { FormatDateTimePipe } from 'app/modules/pipes/format-date-time/format-datetime.pipe';
import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe';

describe('FormatDateTimePipe', () => {
let spectator: SpectatorPipe<FormatDateTimePipe>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class FormatDateTimePipe implements PipeTransform {
const localDate = date;

// Reason for below replacements: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md
// TODO: Replace with formatDateTimeToDateFns in LocaleService
if (this.dateFormat) {
this.dateFormat = this.dateFormat
.replace('YYYY', 'yyyy')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
import { FormatDateTimePipe } from 'app/modules/pipes/format-date-time/format-datetime.pipe';
import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe';
import { LocaleService } from 'app/services/locale.service';

@Component({
Expand Down
62 changes: 62 additions & 0 deletions src/app/modules/dates/services/ix-date-adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NativeDateAdapter } from '@angular/material/core';
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe';
import { LocaleService } from 'app/services/locale.service';
import { IxDateAdapter } from './ix-date-adapter';

describe('IxDateAdapter', () => {
let spectator: SpectatorService<IxDateAdapter>;
const createService = createServiceFactory({
service: IxDateAdapter,
mocks: [LocaleService, FormatDateTimePipe],
providers: [
{ provide: NativeDateAdapter, useValue: {} },
],
});

beforeEach(() => {
spectator = createService();
});

describe('format', () => {
it('should use FormatDateTimePipe for normal long dates', () => {
const mockDate = new Date(2023, 10, 25);
const formattedDate = '25/11/2023';
spectator.inject(FormatDateTimePipe).transform.mockReturnValue(formattedDate);

const result = spectator.service.format(mockDate, { year: 'numeric', month: 'numeric', day: 'numeric' });

expect(spectator.inject(FormatDateTimePipe).transform).toHaveBeenCalledWith(mockDate, null, ' ');
expect(result).toBe(formattedDate);
});

it('should fallback to super.format when short date is requested ("day" is not in the format)', () => {
const mockDate = new Date(2023, 10, 25);
jest.spyOn(NativeDateAdapter.prototype, 'format').mockReturnValue('11/2023');

const result = spectator.service.format(mockDate, { year: 'numeric', month: 'numeric' });

expect(result).toBe('11/2023');
expect(NativeDateAdapter.prototype.format).toHaveBeenCalledWith(mockDate, { year: 'numeric', month: 'numeric' });
});
});

describe('parse', () => {
it('should return null if the value is not a string or is empty', () => {
expect(spectator.service.parse('')).toBeNull();
expect(spectator.service.parse(null)).toBeNull();
expect(spectator.service.parse(undefined)).toBeNull();
});

it('should use LocaleService to parse valid string values', () => {
const dateString = '2023-11-25';
const mockDate = new Date(2023, 10, 25);
spectator.inject(LocaleService).getDateFromString.mockReturnValue(mockDate);

const result = spectator.service.parse(dateString);

expect(spectator.inject(LocaleService).getDateFromString).toHaveBeenCalledWith(dateString);
expect(result).toBe(mockDate);
});
});
});
37 changes: 37 additions & 0 deletions src/app/modules/dates/services/ix-date-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { NativeDateAdapter } from '@angular/material/core';
import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe';
import { LocaleService } from 'app/services/locale.service';

/**
* This is to be provided in components when we need to format and parse a date according
* to the time format user has selected in the preferences.
*
* TODO: It may be better to provide MAT_DATE_FORMATS and use provideDateFnsAdapter instead.
*/
// eslint-disable-next-line angular-file-naming/service-filename-suffix
@Injectable()
export class IxDateAdapter extends NativeDateAdapter {
constructor(
private localeService: LocaleService,
private formatDateTime: FormatDateTimePipe,
) {
super();
}

override format(date: Date, format: { year: string; month: string; day?: string }): string {
if (!('day' in format)) {
return super.format(date, format);
}
// TODO: Pipe does not support disabling time formatting properly.
return this.formatDateTime.transform(date, null, ' ');
}

override parse(value: unknown, _?: unknown): Date | null {
if (typeof value !== 'string' || !value) {
return super.parse(value);
}

return this.localeService.getDateFromString(value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
@if (label() || tooltip()) {
<ix-label
[label]="label()"
[tooltip]="tooltip()"
[required]="required()"
[ixTestOverride]="controlDirective.name"
></ix-label>
}

<div class="input-container" ixRegisteredControl>
<mat-datepicker #datepicker></mat-datepicker>
<input
matInput
[value]="value()"
[placeholder]="placeholder()"
[matDatepicker]="datepicker"
[min]="min()"
[max]="max()"
[ixTest]="controlDirective.name"
[attr.aria-label]="label()"
[readonly]="readonly()"
[disabled]="isDisabled()"
(dateChange)="onDateChanged($event)"
(focus)="datepicker.open()"
(blur)="blurred()"
>
<mat-datepicker-toggle matIconSuffix [for]="datepicker"></mat-datepicker-toggle>
</div>

<ix-errors [control]="controlDirective.control" [label]="label()"></ix-errors>

@if (hint()) {
<mat-hint>{{ hint() }}</mat-hint>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
:host {
display: block;
margin-bottom: 12px;
}

.input-container {
background: var(--bg1);
border: solid 1.5px var(--lines);
border-radius: 2px;
display: flex;
flex-direction: row;
font-size: 12px;
position: relative;

&:focus-within {
border-color: var(--primary);
}

input {
background: none;
border: 0;
color: var(--fg1);
width: 100%;

&:focus {
outline: none;
}
}

::ng-deep mat-datepicker-toggle svg {
width: 18px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { DateAdapter } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDatepickerInputHarness } from '@angular/material/datepicker/testing';
import { MatInputModule } from '@angular/material/input';
import { MatInputHarness } from '@angular/material/input/testing';
import { createHostFactory, mockProvider, SpectatorHost } from '@ngneat/spectator/jest';
import { parseISO } from 'date-fns';
import { MockComponent } from 'ng-mocks';
import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe';
import { IxDateAdapter } from 'app/modules/dates/services/ix-date-adapter';
import { IxErrorsComponent } from 'app/modules/forms/ix-forms/components/ix-errors/ix-errors.component';
import { IxLabelComponent } from 'app/modules/forms/ix-forms/components/ix-label/ix-label.component';
import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component';
import { LocaleService } from 'app/services/locale.service';
import { IxDatepickerComponent } from './ix-date-picker.component';

describe('IxDatePickerComponent', () => {
let spectator: SpectatorHost<IxDatepickerComponent>;
let loader: HarnessLoader;
const formControl = new FormControl<Date>(null);

const createHost = createHostFactory({
component: IxDatepickerComponent,
imports: [
ReactiveFormsModule,
MatDatepickerModule,
MatInputModule,
],
declarations: [
MockComponent(IxErrorsComponent),
MockComponent(IxLabelComponent),
MockComponent(IxIconComponent),
],
providers: [
mockProvider(LocaleService, {
// User timezone is UTC+2, as set in jest config.
// Machine timezone is UTC+1, resulting machine timezone being -1 compared to user timezone.
timezone: 'Europe/Berlin',
}),
],
componentProviders: [
IxDateAdapter,
FormatDateTimePipe,
{
provide: DateAdapter,
deps: [IxDateAdapter],
useFactory: (dateAdapter: IxDateAdapter) => {
jest.spyOn(dateAdapter, 'format').mockImplementation(() => 'January 1st, 2021');
jest.spyOn(dateAdapter, 'parse').mockImplementation(() => new Date(2021, 0, 2, 0, 0, 0));
return dateAdapter;
},
},
],
});

describe('rendering', () => {
it('shows label with form label, tooltip and required values', () => {
spectator = createHost(`<ix-datepicker
label="Label"
tooltip="Tooltip"
[required]="true"
[formControl]="formControl"
></ix-datepicker>`, {
hostProps: {
formControl,
},
});

const label = spectator.query(IxLabelComponent);
expect(label).toExist();
expect(label.label).toBe('Label');
expect(label.tooltip).toBe('Tooltip');
expect(label.required).toBe(true);
});

it('opens datepicker when input is clicked', async () => {
spectator = createHost('<ix-datepicker [formControl]="formControl"></ix-datepicker>', {
hostProps: {
formControl,
},
});
loader = TestbedHarnessEnvironment.loader(spectator.fixture);

const input = await loader.getHarness(MatInputHarness);
await input.focus();

const datepicker = await loader.getHarness(MatDatepickerInputHarness);
expect(await datepicker.isCalendarOpen()).toBe(true);
});

it('passes min and max in browser timezone params to mat-datepicker', async () => {
spectator = createHost('<ix-datepicker [formControl]="formControl" [min]="min" [max]="max"></ix-datepicker>', {
hostProps: {
formControl,
min: new Date(2020, 0, 1, 12, 0, 0),
max: new Date(2020, 0, 2, 12, 0, 0),
},
});
loader = TestbedHarnessEnvironment.loader(spectator.fixture);

const datepicker = await loader.getHarness(MatDatepickerInputHarness);
expect(await datepicker.getMin()).toBe('2020-01-01');
expect(await datepicker.getMax()).toBe('2020-01-02');
});
});

describe('form control', () => {
it('shows form control value in browser timezone in the input', async () => {
spectator = createHost('<ix-datepicker [formControl]="formControl"></ix-datepicker>', {
hostProps: {
formControl,
},
});
loader = TestbedHarnessEnvironment.loader(spectator.fixture);

formControl.setValue(parseISO('2021-01-01T23:00:00+01:00'));

const input = await loader.getHarness(MatInputHarness);

expect(await input.getValue()).toMatch('January 1st, 2021');
});

it('updates form control with date in machine timezone when user types in new date', async () => {
spectator = createHost('<ix-datepicker [formControl]="formControl"></ix-datepicker>', {
hostProps: {
formControl,
},
});
loader = TestbedHarnessEnvironment.loader(spectator.fixture);

const datepicker = await loader.getHarness(MatDatepickerInputHarness);
await datepicker.setValue('January 2nd, 2021');

expect(formControl.value).toEqual(parseISO('2021-01-01T23:00:00Z'));
});

it('updates form control with date in machine timezone when user selects new date in datepicker', async () => {
spectator = createHost('<ix-datepicker [formControl]="formControl"></ix-datepicker>', {
hostProps: {
formControl,
},
});
loader = TestbedHarnessEnvironment.loader(spectator.fixture);

const datepicker = await loader.getHarness(MatDatepickerInputHarness);
await datepicker.openCalendar();
const calendar = await datepicker.getCalendar();
await calendar.changeView(); // Switch to years
await calendar.selectCell({ text: '2024' });
await calendar.selectCell({ text: 'JAN' });
await calendar.selectCell({ text: '4' });

expect(formControl.value).toEqual(parseISO('2024-01-03T23:00:00.000Z'));
});
});
});
Loading

0 comments on commit 485593d

Please sign in to comment.