feat(ui): use Material datepickers for planning horizon

This commit is contained in:
nessar authored and nessar committed 2026-10-08 06:49:06 +00:00
1 parent 31d4333783
commit 6777cbc282
4 files changed
+99 -28

No files matched your search

+4 -4
View File
@@ -352,9 +352,9 @@ describe('Application Settings', () => {
);
const start = period.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = period.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
start.value = '2024-02-29';
start.value = '29/02/2024';
start.dispatchEvent(new Event('input'));
last.value = '2024-03-08';
last.value = '08/03/2024';
last.dispatchEvent(new Event('input'));
await fixture.whenStable();
expect(calculate).toHaveBeenCalledOnce();
@@ -379,10 +379,10 @@ describe('Application Settings', () => {
await revisitApp(true);
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
'2024-02-29',
'29/02/2024',
);
expect(fixture.nativeElement.querySelector('input[name="lastPlanningDate"]').value).toBe(
'2024-03-08',
'08/03/2024',
);
});
+16 -12
View File
@@ -14,41 +14,45 @@
<mat-label>{{ tr().settings.planningStart }}</mat-label>
<input
matInput
type="date"
[matDatepicker]="planningStartPicker"
name="planningStart"
required
#planningStart
[value]="calendarSettings.planningDates().start"
[ngModel]="planningDates().start"
(ngModelChange)="setPlanningDate('start', $event)"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
[attr.aria-describedby]="
calendarSettings.planningHorizonError()
? 'planning-dates-help planning-dates-error'
: '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 appearance="outline">
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
<input
matInput
type="date"
[matDatepicker]="lastPlanningDatePicker"
name="lastPlanningDate"
required
#lastPlanningDate
[value]="calendarSettings.planningDates().lastPlanningDate"
[ngModel]="planningDates().lastPlanningDate"
(ngModelChange)="setPlanningDate('lastPlanningDate', $event)"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
[attr.aria-describedby]="
calendarSettings.planningHorizonError()
? 'planning-dates-help planning-dates-error'
: '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>
@if (calendarSettings.planningHorizonError(); as error) {
<p id="planning-dates-error" class="validation-error" role="alert">
@@ -43,6 +43,7 @@ describe('UserInput', () => {
component = fixture.componentInstance;
userInputService = TestBed.inject(UserInputService);
fixture.detectChanges();
await fixture.whenStable();
}
async function revisit(): Promise<void> {
@@ -200,10 +201,44 @@ describe('UserInput', () => {
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);
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 calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
expect(calculate.disabled).toBe(true);
@@ -212,18 +247,23 @@ describe('UserInput', () => {
calculate.click();
component.triggerComputation();
expect(userInputService.computeTriggerSignal()).toBe(0);
editInput('input[name="planningStart"]', '2024-02-29');
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
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',
(value) => {
async (value) => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-02-29', '2027-02-28');
await fixture.whenStable();
editInput('input[name="planningStart"]', value);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
value,
);
expect(
fixture.nativeElement.querySelector('#planning-dates-error')?.textContent,
).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);
settings.setPlanningDates('2024-02-29', '2027-02-28');
fixture.detectChanges();
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
expect(start.value).toBe('2024-02-29');
expect(last.value).toBe('2027-02-28');
expect(start.value).toBe('29/02/2024');
expect(last.value).toBe('28/02/2027');
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(last.getAttribute('aria-invalid')).toBe('true');
expect(last.getAttribute('aria-describedby')).toContain('planning-dates-error');
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(last.getAttribute('aria-invalid')).toBe('false');
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
+28 -1
View File
@@ -8,6 +8,10 @@ import {
} from '@angular/core';
import { MatInputModule } from '@angular/material/input';
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 { RulesPanel } from '../rules-panel/rules-panel';
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 { CalendarSettingsService } from '../../services/calendar-settings-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({
selector: 'app-user-input',
imports: [
MatInputModule,
MatFormFieldModule,
MatDatepickerModule,
FormsModule,
RulesPanel,
MatCard,
MatCardContent,
@@ -36,6 +43,7 @@ import { addPlanningDays } from '../../services/planning-date';
templateUrl: './user-input.html',
styleUrl: './user-input.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: DAY_FIRST_DATE_PROVIDERS,
})
export class UserInput {
private userInputService: UserInputService = inject(UserInputService);
@@ -45,6 +53,16 @@ export class UserInput {
protected readonly calendarSettings = inject(CalendarSettingsService);
protected readonly duration = inject(PlanningDurationService);
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(() => {
const estimate = this.duration.estimate();
if (!estimate) return '';
@@ -171,6 +189,7 @@ export class UserInput {
);
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
effect(() => {
this.userInputService.planningOutcomeSignal();
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 {
return (
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)