feat(ui): use Material datepickers for planning horizon
This commit is contained in:
1 parent
31d4333783
commit
6777cbc282
4 files changed
+99
-28
No files matched your search
@@ -352,9 +352,9 @@ describe('Application Settings', () => {
|
|||||||
);
|
);
|
||||||
const start = period.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
|
const start = period.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
|
||||||
const last = period.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
|
const last = period.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
|
||||||
start.value = '2024-02-29';
|
start.value = '29/02/2024';
|
||||||
start.dispatchEvent(new Event('input'));
|
start.dispatchEvent(new Event('input'));
|
||||||
last.value = '2024-03-08';
|
last.value = '08/03/2024';
|
||||||
last.dispatchEvent(new Event('input'));
|
last.dispatchEvent(new Event('input'));
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
expect(calculate).toHaveBeenCalledOnce();
|
expect(calculate).toHaveBeenCalledOnce();
|
||||||
@@ -379,10 +379,10 @@ describe('Application Settings', () => {
|
|||||||
|
|
||||||
await revisitApp(true);
|
await revisitApp(true);
|
||||||
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
|
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
|
||||||
'2024-02-29',
|
'29/02/2024',
|
||||||
);
|
);
|
||||||
expect(fixture.nativeElement.querySelector('input[name="lastPlanningDate"]').value).toBe(
|
expect(fixture.nativeElement.querySelector('input[name="lastPlanningDate"]').value).toBe(
|
||||||
'2024-03-08',
|
'08/03/2024',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,41 +14,45 @@
|
|||||||
<mat-label>{{ tr().settings.planningStart }}</mat-label>
|
<mat-label>{{ tr().settings.planningStart }}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
type="date"
|
[matDatepicker]="planningStartPicker"
|
||||||
name="planningStart"
|
name="planningStart"
|
||||||
required
|
required
|
||||||
#planningStart
|
[ngModel]="planningDates().start"
|
||||||
[value]="calendarSettings.planningDates().start"
|
(ngModelChange)="setPlanningDate('start', $event)"
|
||||||
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
|
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
|
||||||
[attr.aria-describedby]="
|
[attr.aria-describedby]="
|
||||||
calendarSettings.planningHorizonError()
|
calendarSettings.planningHorizonError()
|
||||||
? 'planning-dates-help planning-dates-error'
|
? 'planning-dates-help planning-dates-error'
|
||||||
: 'planning-dates-help'
|
: 'planning-dates-help'
|
||||||
"
|
"
|
||||||
(input)="
|
|
||||||
calendarSettings.setPlanningDates(planningStart.value, lastPlanningDate.value)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
|
<mat-datepicker-toggle
|
||||||
|
matIconSuffix
|
||||||
|
[for]="planningStartPicker"
|
||||||
|
></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #planningStartPicker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
|
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
type="date"
|
[matDatepicker]="lastPlanningDatePicker"
|
||||||
name="lastPlanningDate"
|
name="lastPlanningDate"
|
||||||
required
|
required
|
||||||
#lastPlanningDate
|
[ngModel]="planningDates().lastPlanningDate"
|
||||||
[value]="calendarSettings.planningDates().lastPlanningDate"
|
(ngModelChange)="setPlanningDate('lastPlanningDate', $event)"
|
||||||
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
|
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
|
||||||
[attr.aria-describedby]="
|
[attr.aria-describedby]="
|
||||||
calendarSettings.planningHorizonError()
|
calendarSettings.planningHorizonError()
|
||||||
? 'planning-dates-help planning-dates-error'
|
? 'planning-dates-help planning-dates-error'
|
||||||
: 'planning-dates-help'
|
: 'planning-dates-help'
|
||||||
"
|
"
|
||||||
(input)="
|
|
||||||
calendarSettings.setPlanningDates(planningStart.value, lastPlanningDate.value)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
|
<mat-datepicker-toggle
|
||||||
|
matIconSuffix
|
||||||
|
[for]="lastPlanningDatePicker"
|
||||||
|
></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #lastPlanningDatePicker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@if (calendarSettings.planningHorizonError(); as error) {
|
@if (calendarSettings.planningHorizonError(); as error) {
|
||||||
<p id="planning-dates-error" class="validation-error" role="alert">
|
<p id="planning-dates-error" class="validation-error" role="alert">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe('UserInput', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
userInputService = TestBed.inject(UserInputService);
|
userInputService = TestBed.inject(UserInputService);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revisit(): Promise<void> {
|
async function revisit(): Promise<void> {
|
||||||
@@ -200,10 +201,44 @@ describe('UserInput', () => {
|
|||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(userInputService.computeTriggerSignal()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents calculation while planning dates are invalid and re-enables it after correction', () => {
|
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
|
||||||
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
|
settings.setPlanningDates('2024-02-29', '2024-03-08');
|
||||||
|
TestBed.inject(TranslationService).setLanguage('fr');
|
||||||
|
await fixture.whenStable();
|
||||||
|
const toggles = fixture.nativeElement.querySelectorAll(
|
||||||
|
'.planning-dates mat-datepicker-toggle button',
|
||||||
|
);
|
||||||
|
expect(toggles).toHaveLength(2);
|
||||||
|
for (const [index, day] of ['27', '9'].entries()) {
|
||||||
|
toggles[index].click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const calendar = document.querySelector<HTMLElement>('mat-calendar')!;
|
||||||
|
if (index === 0)
|
||||||
|
expect(
|
||||||
|
calendar
|
||||||
|
.querySelector('.mat-calendar-period-button')
|
||||||
|
?.textContent?.toLowerCase(),
|
||||||
|
).toContain('févr');
|
||||||
|
Array.from(calendar.querySelectorAll<HTMLButtonElement>('.mat-calendar-body-cell'))
|
||||||
|
.find((cell) => cell.textContent?.trim() === day)!
|
||||||
|
.click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await expect.poll(() => document.querySelector('mat-calendar')).toBeNull();
|
||||||
|
}
|
||||||
|
expect(settings.planningHorizon()).toEqual({ start: '2024-02-27', end: '2024-03-10' });
|
||||||
|
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
|
||||||
|
start: '2024-02-27',
|
||||||
|
lastPlanningDate: '2024-03-09',
|
||||||
|
});
|
||||||
|
expect(userInputService.computeTriggerSignal()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
|
||||||
const settings = TestBed.inject(CalendarSettingsService);
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
settings.setPlanningDates('2024-03-01', '2024-03-01');
|
settings.setPlanningDates('2024-03-01', '2024-03-01');
|
||||||
editInput('input[name="lastPlanningDate"]', '2024-02-29');
|
await fixture.whenStable();
|
||||||
|
editInput('input[name="lastPlanningDate"]', '29/02/2024');
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
|
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
|
||||||
expect(calculate.disabled).toBe(true);
|
expect(calculate.disabled).toBe(true);
|
||||||
@@ -212,18 +247,23 @@ describe('UserInput', () => {
|
|||||||
calculate.click();
|
calculate.click();
|
||||||
component.triggerComputation();
|
component.triggerComputation();
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(userInputService.computeTriggerSignal()).toBe(0);
|
||||||
editInput('input[name="planningStart"]', '2024-02-29');
|
editInput('input[name="planningStart"]', '29/02/2024');
|
||||||
expect(calculate.disabled).toBe(false);
|
expect(calculate.disabled).toBe(false);
|
||||||
calculate.click();
|
calculate.click();
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(1);
|
expect(userInputService.computeTriggerSignal()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['', '2024-02-30', 'not-a-date'])(
|
it.each(['', '30/02/2024', 'not-a-date', '2'])(
|
||||||
'reports an invalid planning date without saving it: %s',
|
'reports an invalid planning date without saving it: %s',
|
||||||
(value) => {
|
async (value) => {
|
||||||
const settings = TestBed.inject(CalendarSettingsService);
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
settings.setPlanningDates('2024-02-29', '2027-02-28');
|
settings.setPlanningDates('2024-02-29', '2027-02-28');
|
||||||
|
await fixture.whenStable();
|
||||||
editInput('input[name="planningStart"]', value);
|
editInput('input[name="planningStart"]', value);
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
|
||||||
|
value,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
fixture.nativeElement.querySelector('#planning-dates-error')?.textContent,
|
fixture.nativeElement.querySelector('#planning-dates-error')?.textContent,
|
||||||
).toContain('two valid calendar dates');
|
).toContain('two valid calendar dates');
|
||||||
@@ -235,22 +275,22 @@ describe('UserInput', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('edits inclusive dates with inline validation while preserving the saved planning horizon', () => {
|
it('edits inclusive dates with inline validation while preserving the saved planning horizon', async () => {
|
||||||
const settings = TestBed.inject(CalendarSettingsService);
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
settings.setPlanningDates('2024-02-29', '2027-02-28');
|
settings.setPlanningDates('2024-02-29', '2027-02-28');
|
||||||
fixture.detectChanges();
|
await fixture.whenStable();
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
|
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
|
||||||
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
|
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
|
||||||
expect(start.value).toBe('2024-02-29');
|
expect(start.value).toBe('29/02/2024');
|
||||||
expect(last.value).toBe('2027-02-28');
|
expect(last.value).toBe('28/02/2027');
|
||||||
expect(root.textContent).toContain('Last planning date (inclusive)');
|
expect(root.textContent).toContain('Last planning date (inclusive)');
|
||||||
editInput('input[name="lastPlanningDate"]', '2024-02-28');
|
editInput('input[name="lastPlanningDate"]', '28/02/2024');
|
||||||
expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after');
|
expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after');
|
||||||
expect(last.getAttribute('aria-invalid')).toBe('true');
|
expect(last.getAttribute('aria-invalid')).toBe('true');
|
||||||
expect(last.getAttribute('aria-describedby')).toContain('planning-dates-error');
|
expect(last.getAttribute('aria-describedby')).toContain('planning-dates-error');
|
||||||
expect(settings.planningHorizon()).toBeNull();
|
expect(settings.planningHorizon()).toBeNull();
|
||||||
editInput('input[name="lastPlanningDate"]', '2024-02-29');
|
editInput('input[name="lastPlanningDate"]', '29/02/2024');
|
||||||
expect(root.querySelector('#planning-dates-error')).toBeNull();
|
expect(root.querySelector('#planning-dates-error')).toBeNull();
|
||||||
expect(last.getAttribute('aria-invalid')).toBe('false');
|
expect(last.getAttribute('aria-invalid')).toBe('false');
|
||||||
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
|
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { DateAdapter } from '@angular/material/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
import { RulesPanel } from '../rules-panel/rules-panel';
|
import { RulesPanel } from '../rules-panel/rules-panel';
|
||||||
import { MatCard, MatCardContent } from '@angular/material/card';
|
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||||
@@ -19,13 +23,16 @@ import type { LeaveType } from '../../services/leave-planner.service';
|
|||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
import { addPlanningDays } from '../../services/planning-date';
|
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
|
||||||
|
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-input',
|
selector: 'app-user-input',
|
||||||
imports: [
|
imports: [
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
FormsModule,
|
||||||
RulesPanel,
|
RulesPanel,
|
||||||
MatCard,
|
MatCard,
|
||||||
MatCardContent,
|
MatCardContent,
|
||||||
@@ -36,6 +43,7 @@ import { addPlanningDays } from '../../services/planning-date';
|
|||||||
templateUrl: './user-input.html',
|
templateUrl: './user-input.html',
|
||||||
styleUrl: './user-input.scss',
|
styleUrl: './user-input.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
providers: DAY_FIRST_DATE_PROVIDERS,
|
||||||
})
|
})
|
||||||
export class UserInput {
|
export class UserInput {
|
||||||
private userInputService: UserInputService = inject(UserInputService);
|
private userInputService: UserInputService = inject(UserInputService);
|
||||||
@@ -45,6 +53,16 @@ export class UserInput {
|
|||||||
protected readonly calendarSettings = inject(CalendarSettingsService);
|
protected readonly calendarSettings = inject(CalendarSettingsService);
|
||||||
protected readonly duration = inject(PlanningDurationService);
|
protected readonly duration = inject(PlanningDurationService);
|
||||||
protected readonly translationService = inject(TranslationService);
|
protected readonly translationService = inject(TranslationService);
|
||||||
|
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
||||||
|
protected readonly planningDates = computed(() => {
|
||||||
|
const { start, lastPlanningDate } = this.calendarSettings.planningDates();
|
||||||
|
return {
|
||||||
|
start: isPlanningDate(start) ? new Date(`${start}T00:00:00`) : null,
|
||||||
|
lastPlanningDate: isPlanningDate(lastPlanningDate)
|
||||||
|
? new Date(`${lastPlanningDate}T00:00:00`)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
protected readonly durationLabel = computed(() => {
|
protected readonly durationLabel = computed(() => {
|
||||||
const estimate = this.duration.estimate();
|
const estimate = this.duration.estimate();
|
||||||
if (!estimate) return '';
|
if (!estimate) return '';
|
||||||
@@ -171,6 +189,7 @@ export class UserInput {
|
|||||||
);
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.userInputService.planningOutcomeSignal();
|
this.userInputService.planningOutcomeSignal();
|
||||||
this.historyBalanceId.set(null);
|
this.historyBalanceId.set(null);
|
||||||
@@ -178,6 +197,14 @@ export class UserInput {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected setPlanningDate(field: 'start' | 'lastPlanningDate', value: Date | null): void {
|
||||||
|
const dates = {
|
||||||
|
...this.calendarSettings.planningDates(),
|
||||||
|
[field]: value ? dayjs(value).format('YYYY-MM-DD') : '',
|
||||||
|
};
|
||||||
|
this.calendarSettings.setPlanningDates(dates.start, dates.lastPlanningDate);
|
||||||
|
}
|
||||||
|
|
||||||
private ruleName(id: string): string {
|
private ruleName(id: string): string {
|
||||||
return (
|
return (
|
||||||
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
|
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
|
||||||
|
|||||||
Reference in new issue
Block a user