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 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',
); );
}); });
+16 -12
View File
@@ -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');
+28 -1
View File
@@ -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)