Merge branch 'develop' into feature/5032-Filter-Menu-Refinement

This commit is contained in:
Lorenz Hilpert
2025-04-11 16:36:57 +02:00
93 changed files with 3779 additions and 193 deletions

View File

@@ -14,7 +14,10 @@ export class CanActivateGoodsInGuard {
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const pid = this._config.get('process.ids.goodsIn', z.number());
const process = await this._applicationService.getProcessById$(pid).pipe(first()).toPromise();
const process = await this._applicationService
.getProcessById$(pid)
.pipe(first())
.toPromise();
if (!process) {
await this._applicationService.createProcess({
id: this._config.get('process.ids.goodsIn'),
@@ -23,7 +26,9 @@ export class CanActivateGoodsInGuard {
name: '',
});
}
this._applicationService.activateProcess(this._config.get('process.ids.goodsIn'));
this._applicationService.activateProcess(
this._config.get('process.ids.goodsIn'),
);
return true;
}
}

View File

@@ -1,8 +1,11 @@
import { enableProdMode, isDevMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { CONFIG_DATA } from '@isa/core/config';
import { setDefaultOptions } from 'date-fns';
import { de } from 'date-fns/locale';
import * as moment from 'moment';
setDefaultOptions({ locale: de });
moment.locale('de');
import { AppModule } from './app/app.module';
@@ -16,7 +19,9 @@ async function bootstrap() {
const config = await configRes.json();
platformBrowserDynamic([{ provide: CONFIG_DATA, useValue: config }]).bootstrapModule(AppModule);
platformBrowserDynamic([
{ provide: CONFIG_DATA, useValue: config },
]).bootstrapModule(AppModule);
}
try {
@@ -24,4 +29,3 @@ try {
} catch (error) {
console.error(error);
}

View File

@@ -12,22 +12,45 @@
<form [formGroup]="formGroup" (ngSubmit)="save()">
<ng-container *ngIf="isBusinessKonto$ | async">
<shared-form-control label="Firma" class="col-span-2">
<input class="input-control" placeholder="Firma" type="text" formControlName="organisation" tabindex="1" />
<shared-form-control
[label]="formGroup.controls.organisation.errors?.required ? 'Firma *' : 'Firma'"
class="col-span-2"
>
<input
class="input-control"
placeholder="Firma"
type="text"
formControlName="organisation"
tabindex="1"
/>
</shared-form-control>
<shared-form-control label="Abteilung">
<input class="input-control" placeholder="Abteilung" type="text" formControlName="department" tabindex="2" />
<input
class="input-control"
placeholder="Abteilung"
type="text"
formControlName="department"
tabindex="2"
/>
</shared-form-control>
<shared-form-control label="USt-ID">
<input class="input-control" placeholder="Abteilung" type="text" formControlName="vatId" tabindex="3" />
<input
class="input-control"
placeholder="Abteilung"
type="text"
formControlName="vatId"
tabindex="3"
/>
</shared-form-control>
</ng-container>
<shared-form-control label="Anrede">
<shared-select formControlName="gender" placeholder="Anrede" tabindex="4" [autofocus]="true">
<shared-select-option *ngFor="let gender of genderSettings.genders" [value]="gender.value">{{ gender.label }}</shared-select-option>
<shared-select-option *ngFor="let gender of genderSettings.genders" [value]="gender.value">{{
gender.label
}}</shared-select-option>
</shared-select>
</shared-form-control>
@@ -42,50 +65,107 @@
</shared-select>
</shared-form-control>
<shared-form-control label="Nachname">
<input class="input-control" placeholder="Nachname" type="text" formControlName="lastName" tabindex="6" />
<shared-form-control
[label]="formGroup.controls.organisation.errors?.required ? 'Nachname *' : 'Nachname'"
>
<input
class="input-control"
placeholder="Nachname"
type="text"
formControlName="lastName"
tabindex="6"
/>
</shared-form-control>
<shared-form-control label="Vorname">
<input class="input-control" placeholder="Vorname" type="text" formControlName="firstName" tabindex="7" />
<shared-form-control
[label]="formGroup.controls.organisation.errors?.required ? 'Vorname *' : 'Vorname'"
>
<input
class="input-control"
placeholder="Vorname"
type="text"
formControlName="firstName"
tabindex="7"
/>
</shared-form-control>
<ng-container *ngIf="!(isBusinessKonto$ | async)">
<shared-form-control label="Firma" class="col-span-2">
<input class="input-control" placeholder="Firma" type="text" formControlName="organisation" tabindex="8" />
<input
class="input-control"
placeholder="Firma"
type="text"
formControlName="organisation"
tabindex="8"
/>
</shared-form-control>
</ng-container>
<shared-form-control label="Straße">
<input class="input-control" placeholder="Straße" type="text" formControlName="street" tabindex="9" />
<input
class="input-control"
placeholder="Straße"
type="text"
formControlName="street"
tabindex="9"
/>
</shared-form-control>
<shared-form-control label="Hausnummer">
<input class="input-control" placeholder="Hausnummer" type="text" formControlName="streetNumber" tabindex="10" />
<input
class="input-control"
placeholder="Hausnummer"
type="text"
formControlName="streetNumber"
tabindex="10"
/>
</shared-form-control>
<shared-form-control label="PLZ">
<input class="input-control" placeholder="PLZ" type="text" formControlName="zipCode" tabindex="11" />
<input
class="input-control"
placeholder="PLZ"
type="text"
formControlName="zipCode"
tabindex="11"
/>
</shared-form-control>
<shared-form-control label="Ort">
<input class="input-control" placeholder="Ort" type="text" formControlName="city" tabindex="12" />
<input
class="input-control"
placeholder="Ort"
type="text"
formControlName="city"
tabindex="12"
/>
</shared-form-control>
<shared-form-control label="Adresszusatz" class="col-span-2">
<input class="input-control" placeholder="Adresszusatz" type="text" formControlName="info" tabindex="13" />
<input
class="input-control"
placeholder="Adresszusatz"
type="text"
formControlName="info"
tabindex="13"
/>
</shared-form-control>
<shared-form-control class="col-span-2" label="Land">
<shared-select placeholder="Land" formControlName="country" tabindex="14">
<shared-select-option *ngFor="let country of countries$ | async" [value]="country.isO3166_A_3">
<shared-select-option
*ngFor="let country of countries$ | async"
[value]="country.isO3166_A_3"
>
{{ country.name }}
</shared-select-option>
</shared-select>
</shared-form-control>
<div class="text-center col-span-2">
<shared-checkbox formControlName="isDefault">Diese Lieferadresse als Standard Adresse festlegen</shared-checkbox>
<shared-checkbox formControlName="isDefault"
>Diese Lieferadresse als Standard Adresse festlegen</shared-checkbox
>
</div>
<div class="mt-6 text-center col-span-2">
<button
@@ -98,3 +178,4 @@
</button>
</div>
</form>

View File

@@ -15,6 +15,7 @@ import { RouterLink } from '@angular/router';
import { IconComponent } from '@shared/components/icon';
import { zipCodeValidator } from '../../validators/zip-code-validator';
import { GenderSettingsService } from '@shared/services/gender';
import { validateCompanyOrPersonalInfoRequired } from '../../validators/gender-b2b-validator';
@Component({
selector: 'page-add-shipping-address-main-view',
@@ -70,10 +71,30 @@ export class AddShippingAddressMainViewComponent implements OnInit, OnDestroy {
ngOnInit() {
this._store.customer$.pipe(takeUntil(this._onDestroy)).subscribe(() => {
// Dynamic validation rules based on account type
// For business accounts (isBusinessKonto), we apply different validation rules
// than for personal accounts
if (this._store.isBusinessKonto) {
this.formGroup.controls.organisation.setValidators([Validators.required]);
// For business accounts:
// - Clear individual validators from personal info fields
// - Add a form-level validator that requires either company OR personal info
// - Make address fields optional to support different business address formats
this.formGroup.controls.organisation.setValidators([]);
this.formGroup.controls.gender.setValidators([]);
this.formGroup.controls.firstName.setValidators([]);
this.formGroup.controls.lastName.setValidators([]);
this.formGroup.setValidators([validateCompanyOrPersonalInfoRequired]);
this.formGroup.controls.street.setValidators([]);
this.formGroup.controls.streetNumber.setValidators([]);
} else {
// For personal accounts:
// - Organization info is optional
// - First and last name are required
// - No special form-level validators needed
this.formGroup.controls.organisation.clearValidators();
this.formGroup.controls.firstName.setValidators([Validators.required]);
this.formGroup.controls.lastName.setValidators([Validators.required]);
this.formGroup.setValidators([]);
}
});
}
@@ -133,7 +154,10 @@ export class AddShippingAddressMainViewComponent implements OnInit, OnDestroy {
formData.isDefault,
);
this._navigation.navigateToDetails({ processId: this._store.processId, customerId: this._store.customerId });
this._navigation.navigateToDetails({
processId: this._store.processId,
customerId: this._store.customerId,
});
} catch (error) {
this.formGroup.enable();
}

View File

@@ -5,9 +5,10 @@ import { UiCommonModule } from '@ui/common';
import { UiFormControlModule } from '@ui/form-control';
import { UiSelectModule } from '@ui/select';
import { UiIconModule } from '@ui/icon';
import { ReactiveFormsModule } from '@angular/forms';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { UiDateInputDirective } from '@ui/input';
import { validateCompanyOrPersonalInfoRequired } from '../../validators/gender-b2b-validator';
@Component({
selector: 'page-customer-data-edit-b2b',
@@ -26,8 +27,9 @@ import { UiDateInputDirective } from '@ui/input';
],
})
export class CustomerDataEditB2BComponent extends CustomerDataEditComponent {
afterInitForm = (control) => {
afterInitForm = (control: FormGroup) => {
control.get('lastName').setValidators([]);
control.get('firstName').setValidators([]);
control.setValidators([validateCompanyOrPersonalInfoRequired]);
};
}

View File

@@ -12,22 +12,49 @@
<form [formGroup]="formGroup" (ngSubmit)="save()">
<ng-container *ngIf="isBusinessKonto$ | async">
<shared-form-control label="Firma" class="col-span-2">
<input class="input-control" placeholder="Firma" type="text" formControlName="organisation" tabindex="1" />
<shared-form-control
[label]="formGroup.controls.organisation.errors?.required ? 'Firma *' : 'Firma'"
class="col-span-2"
[hasRequiredMark]="
formGroup.controls.organisation.errors?.required ||
formGroup.errors?.eitherCompanyOrPersonalRequired
"
>
<input
class="input-control"
placeholder="Firma"
type="text"
formControlName="organisation"
tabindex="1"
/>
</shared-form-control>
<shared-form-control label="Abteilung">
<input class="input-control" placeholder="Abteilung" type="text" formControlName="department" tabindex="2" />
<input
class="input-control"
placeholder="Abteilung"
type="text"
formControlName="department"
tabindex="2"
/>
</shared-form-control>
<shared-form-control label="USt-ID">
<input class="input-control" placeholder="Abteilung" type="text" formControlName="vatId" tabindex="3" />
<input
class="input-control"
placeholder="Abteilung"
type="text"
formControlName="vatId"
tabindex="3"
/>
</shared-form-control>
</ng-container>
<shared-form-control label="Anrede">
<shared-select formControlName="gender" placeholder="Anrede" tabindex="4" [autofocus]="true">
<shared-select-option *ngFor="let gender of genderSettings.genders" [value]="gender.value">{{ gender.label }}</shared-select-option>
<shared-select-option *ngFor="let gender of genderSettings.genders" [value]="gender.value">{{
gender.label
}}</shared-select-option>
</shared-select>
</shared-form-control>
@@ -42,50 +69,115 @@
</shared-select>
</shared-form-control>
<shared-form-control label="Nachname">
<input class="input-control" placeholder="Nachname" type="text" formControlName="lastName" tabindex="6" />
<shared-form-control
[label]="formGroup.controls.organisation.errors?.required ? 'Nachname *' : 'Nachname'"
[hasRequiredMark]="
formGroup.controls.lastName.errors?.required ||
formGroup.errors?.eitherCompanyOrPersonalRequired
"
>
<input
class="input-control"
placeholder="Nachname"
type="text"
formControlName="lastName"
tabindex="6"
/>
</shared-form-control>
<shared-form-control label="Vorname">
<input class="input-control" placeholder="Vorname" type="text" formControlName="firstName" tabindex="7" />
<shared-form-control
[label]="formGroup.controls.organisation.errors?.required ? 'Vorname *' : 'Vorname'"
[hasRequiredMark]="
formGroup.controls.firstName.errors?.required ||
formGroup.errors?.eitherCompanyOrPersonalRequired
"
>
<input
class="input-control"
placeholder="Vorname"
type="text"
formControlName="firstName"
tabindex="7"
/>
</shared-form-control>
<ng-container *ngIf="!(isBusinessKonto$ | async)">
<shared-form-control label="Firma" class="col-span-2">
<input class="input-control" placeholder="Firma" type="text" formControlName="organisation" tabindex="8" />
<input
class="input-control"
placeholder="Firma"
type="text"
formControlName="organisation"
tabindex="8"
/>
</shared-form-control>
</ng-container>
<shared-form-control label="Straße">
<input class="input-control" placeholder="Straße" type="text" formControlName="street" tabindex="9" />
<input
class="input-control"
placeholder="Straße"
type="text"
formControlName="street"
tabindex="9"
/>
</shared-form-control>
<shared-form-control label="Hausnummer">
<input class="input-control" placeholder="Hausnummer" type="text" formControlName="streetNumber" tabindex="10" />
<input
class="input-control"
placeholder="Hausnummer"
type="text"
formControlName="streetNumber"
tabindex="10"
/>
</shared-form-control>
<shared-form-control label="PLZ">
<input class="input-control" placeholder="PLZ" type="text" formControlName="zipCode" tabindex="11" />
<input
class="input-control"
placeholder="PLZ"
type="text"
formControlName="zipCode"
tabindex="11"
/>
</shared-form-control>
<shared-form-control label="Ort">
<input class="input-control" placeholder="Ort" type="text" formControlName="city" tabindex="12" />
<input
class="input-control"
placeholder="Ort"
type="text"
formControlName="city"
tabindex="12"
/>
</shared-form-control>
<shared-form-control label="Adresszusatz" class="col-span-2">
<input class="input-control" placeholder="Adresszusatz" type="text" formControlName="info" tabindex="13" />
<input
class="input-control"
placeholder="Adresszusatz"
type="text"
formControlName="info"
tabindex="13"
/>
</shared-form-control>
<shared-form-control class="col-span-2" label="Land">
<shared-select placeholder="Land" formControlName="country" tabindex="14">
<shared-select-option *ngFor="let country of countries$ | async" [value]="country.isO3166_A_3">
<shared-select-option
*ngFor="let country of countries$ | async"
[value]="country.isO3166_A_3"
>
{{ country.name }}
</shared-select-option>
</shared-select>
</shared-form-control>
<div class="text-center col-span-2">
<shared-checkbox formControlName="isDefault">Diese Lieferadresse als Standard Adresse festlegen</shared-checkbox>
<shared-checkbox formControlName="isDefault"
>Diese Lieferadresse als Standard Adresse festlegen</shared-checkbox
>
</div>
<div class="mt-6 text-center col-span-2">
<button
@@ -98,3 +190,4 @@
</button>
</div>
</form>

View File

@@ -1,4 +1,11 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject, ChangeDetectorRef } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
inject,
ChangeDetectorRef,
} from '@angular/core';
import { CheckboxComponent } from '@shared/components/checkbox';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { SelectModule } from '@shared/components/select';
@@ -16,6 +23,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { ComponentStore } from '@ngrx/component-store';
import { zipCodeValidator } from '../../validators/zip-code-validator';
import { GenderSettingsService } from '@shared/services/gender';
import { validateCompanyOrPersonalInfoRequired } from '../../validators/gender-b2b-validator';
export interface EditShippingAddressMainViewState {
shippingAddress?: ShippingAddressDTO;
@@ -102,9 +110,15 @@ export class EditShippingAddressMainViewComponent
this._store.customer$.pipe(takeUntil(this._onDestroy)).subscribe(() => {
if (this._store.isBusinessKonto) {
this.formGroup.controls.organisation.setValidators([Validators.required]);
this.formGroup.controls.organisation.setValidators([]);
this.formGroup.controls.firstName.setValidators([]);
this.formGroup.controls.lastName.setValidators([]);
this.formGroup.setValidators([validateCompanyOrPersonalInfoRequired]);
} else {
this.formGroup.controls.organisation.clearValidators();
this.formGroup.controls.firstName.setValidators([Validators.required]);
this.formGroup.controls.lastName.setValidators([Validators.required]);
this.formGroup.setValidators([]);
}
});
}
@@ -185,7 +199,10 @@ export class EditShippingAddressMainViewComponent
formData.isDefault,
);
this._navigation.navigateToDetails({ processId: this._store.processId, customerId: this._store.customerId });
this._navigation.navigateToDetails({
processId: this._store.processId,
customerId: this._store.customerId,
});
} catch (error) {
this.formGroup.enable();
}

View File

@@ -26,6 +26,8 @@ export function requireGenderWhenNameIsSet(control: UntypedFormGroup): Validatio
const genderControl = control.get('gender');
const nameControl = control.get('lastName');
genderControl?.setErrors(null);
if (nameControl && !Validators.required(nameControl)) {
const errors = Validators.min(1)(genderControl) ? { required: true } : null;
@@ -36,3 +38,62 @@ export function requireGenderWhenNameIsSet(control: UntypedFormGroup): Validatio
}
return null;
}
/**
* Angular form validator that implements mutual exclusivity between organisation name and personal details.
* Either:
* 1. Organisation name must be provided (company case), OR
* 2. Both first name and last name must be provided (individual case)
*
* @param control - The form group containing 'firstName', 'lastName', and nested 'organisation.name' controls
* @returns Validation errors if the mutual exclusivity rule is violated, null otherwise
*/
export function validateCompanyOrPersonalInfoRequired(
control: UntypedFormGroup,
): ValidationErrors | null {
const firstNameControl = control.get('firstName');
const lastNameControl = control.get('lastName');
const organisationControl = control.get('organisation');
// Safely access the nested company name, with null checks
const companyNameControl = organisationControl?.get('name') ?? organisationControl;
if (!firstNameControl || !lastNameControl || !companyNameControl) {
// If any control is missing, we can't validate
return null;
}
const hasCompanyName = !!companyNameControl.value;
const hasFirstName = !!firstNameControl.value;
const hasLastName = !!lastNameControl.value;
const hasPersonalInfo = hasFirstName || hasLastName;
// Clear previous validation errors
firstNameControl.setErrors(null);
lastNameControl.setErrors(null);
companyNameControl.setErrors(null);
// Case 1: Neither company name nor personal info provided - require one of them
if (!hasCompanyName && !hasPersonalInfo) {
const errors = { required: true };
companyNameControl.setErrors(errors);
firstNameControl.setErrors(errors);
lastNameControl.setErrors(errors);
return { eitherCompanyOrPersonalRequired: true };
}
// Case 2: First name provided but last name missing
if (hasFirstName && !hasLastName) {
lastNameControl.setErrors({ required: true });
return { lastNameRequired: true };
}
// Case 3: Last name provided but first name missing
if (!hasFirstName && hasLastName) {
firstNameControl.setErrors({ required: true });
return { firstNameRequired: true };
}
// Valid: Either company name or complete personal info is provided
return null;
}

View File

@@ -1,20 +1,38 @@
<form *ngIf="control" [formGroup]="control" (submit)="submit()">
<div class="header">
{{ items[0]?.organisation }}
<ng-container *ngIf="!!items[0]?.organisation && (!!items[0]?.firstName || !!items[0]?.lastName)">-</ng-container>
<ng-container
*ngIf="
!!items[0]?.organisation &&
(!!items[0]?.firstName || !!items[0]?.lastName)
"
>-</ng-container
>
{{ items[0]?.lastName }}
{{ items[0]?.firstName }}
</div>
<ui-form-control label="Vorgang-ID" variant="inline" statusLabel="Nicht Änderbar">
<ui-form-control
label="Vorgang-ID"
variant="inline"
statusLabel="Nicht Änderbar"
>
<input uiInput formControlName="orderNumber" />
</ui-form-control>
<ui-form-control label="Bestelldatum" variant="inline" statusLabel="Nicht Änderbar">
<ui-form-control
label="Bestelldatum"
variant="inline"
statusLabel="Nicht Änderbar"
>
<input uiInput formControlName="orderDate" />
</ui-form-control>
<ui-form-control label="Bestellkanal" variant="inline" statusLabel="Nicht Änderbar">
<ui-form-control
label="Bestellkanal"
variant="inline"
statusLabel="Nicht Änderbar"
>
<input uiInput formControlName="clientChannel" />
</ui-form-control>
@@ -25,40 +43,81 @@
formGroupName="notificationChannel"
></shared-notification-channel-control>
<ui-form-control label="Kundennummer" variant="inline" statusLabel="Nicht Änderbar">
<ui-form-control
label="Kundennummer"
variant="inline"
statusLabel="Nicht Änderbar"
>
<input uiInput formControlName="buyerNumber" />
</ui-form-control>
<ng-container *ngIf="showNameFields">
<ui-form-control label="Name" variant="inline" [statusLabel]="canEditNameFields ? '' : 'Nicht Änderbar'">
<input uiInput formControlName="firstName" />
</ui-form-control>
<ui-form-control label="Vorname" variant="inline" [statusLabel]="canEditNameFields ? '' : 'Nicht Änderbar'">
<ui-form-control
label="Name"
variant="inline"
[statusLabel]="canEditNameFields ? '' : 'Nicht Änderbar'"
>
<input uiInput formControlName="lastName" />
</ui-form-control>
<ui-form-control *ngIf="isB2B" label="Firmenname" variant="inline" [statusLabel]="canEditNameFields ? '' : 'Nicht Änderbar'">
<ui-form-control
label="Vorname"
variant="inline"
[statusLabel]="canEditNameFields ? '' : 'Nicht Änderbar'"
>
<input uiInput formControlName="firstName" />
</ui-form-control>
<ui-form-control
*ngIf="isB2B"
label="Firmenname"
variant="inline"
[statusLabel]="canEditNameFields ? '' : 'Nicht Änderbar'"
>
<input uiInput formControlName="organisation" />
</ui-form-control>
</ng-container>
<div formArrayName="items">
<div *ngFor="let item of itemsControl.controls; index as i" [formGroupName]="i">
<div
*ngFor="let item of itemsControl.controls; index as i"
[formGroupName]="i"
>
<div class="item-header-wrapper">
<div class="item-header">
<img class="cover" *ngIf="item.value?.ean | productImage; let productImage" [src]="productImage" [alt]="item.value?.name" />
<img
class="cover"
*ngIf="item.value?.ean | productImage; let productImage"
[src]="productImage"
[alt]="item.value?.name"
/>
<span class="title">{{ item.value?.name }}</span>
</div>
<button class="cta-expand" type="button" (click)="expanded[i] = !expanded[i]">
<ui-icon icon="arrow_head" size="16px" [rotate]="expanded[i] ? '90deg' : '270deg'"></ui-icon>
<button
class="cta-expand"
type="button"
(click)="expanded[i] = !expanded[i]"
>
<ui-icon
icon="arrow_head"
size="16px"
[rotate]="expanded[i] ? '90deg' : '270deg'"
></ui-icon>
</button>
</div>
<ng-container *ngIf="expanded[i]">
<ui-form-control label="Abholfachnummer" [clearable]="true" variant="inline">
<input pattern="^\d{3,4}_\d{4}_\d{1,5}$" uiInput formControlName="compartmentCode" />
<ui-form-control
label="Abholfachnummer"
[clearable]="true"
variant="inline"
>
<input
pattern="^\d{3,4}_\d{4}_\d{1,5}$"
uiInput
formControlName="compartmentCode"
/>
</ui-form-control>
<shared-goods-in-out-order-details-tags
@@ -66,7 +125,11 @@
formControlName="compartmentInfo"
></shared-goods-in-out-order-details-tags>
<ui-form-control label="Status" variant="inline" statusLabel="Nicht Änderbar">
<ui-form-control
label="Status"
variant="inline"
statusLabel="Nicht Änderbar"
>
<input uiInput formControlName="processingStatus" />
</ui-form-control>
@@ -82,19 +145,37 @@
</ng-container>
<ng-container *ngSwitchCase="16777216"></ng-container>
<ng-container *ngSwitchDefault>
<ui-form-control class="datepicker" label="Geändert" variant="inline">
<ui-form-control
class="datepicker"
label="Geändert"
variant="inline"
>
<input uiInput formControlName="processingStatusDate" />
</ui-form-control>
</ng-container>
</div>
<ng-template #vslLieferdatum>
<ui-form-control class="datepicker" label="vsl. Lieferdatum" variant="inline">
<button class="date-btn" type="button" [uiOverlayTrigger]="uiDatepicker" #datepicker="uiOverlayTrigger">
<ui-form-control
class="datepicker"
label="vsl. Lieferdatum"
variant="inline"
>
<button
class="date-btn"
type="button"
[uiOverlayTrigger]="uiDatepicker"
#datepicker="uiOverlayTrigger"
>
<strong>
{{ items[i]?.estimatedShippingDate | date: 'dd.MM.yy' }}
</strong>
<ui-icon icon="arrow_head" class="dp-button-icon" size="12px" [rotate]="datepicker.opened ? '270deg' : '90deg'"></ui-icon>
<ui-icon
icon="arrow_head"
class="dp-button-icon"
size="12px"
[rotate]="datepicker.opened ? '270deg' : '90deg'"
></ui-icon>
</button>
<ui-datepicker
formControlName="estimatedShippingDate"
@@ -105,13 +186,20 @@
[disabledDaysOfWeek]="[0]"
[selected]="items[i]?.estimatedShippingDate"
saveLabel="Übernehmen"
(save)="changeEstimatedDeliveryDate($event, items[i]); uiDatepicker.close()"
(save)="
changeEstimatedDeliveryDate($event, items[i]);
uiDatepicker.close()
"
></ui-datepicker>
</ui-form-control>
</ng-template>
<ng-template #abholfrist>
<ui-form-control class="datepicker" label="Abholfrist" variant="inline">
<ui-form-control
class="datepicker"
label="Abholfrist"
variant="inline"
>
<button
[uiOverlayTrigger]="deadlineDatepicker"
#deadlineDatepickerTrigger="uiOverlayTrigger"
@@ -122,7 +210,11 @@
<strong>
{{ items[i]?.pickUpDeadline | date: 'dd.MM.yy' }}
</strong>
<ui-icon [rotate]="deadlineDatepickerTrigger.opened ? '270deg' : '90deg'" class="dp-button-icon" icon="arrow_head"></ui-icon>
<ui-icon
[rotate]="deadlineDatepickerTrigger.opened ? '270deg' : '90deg'"
class="dp-button-icon"
icon="arrow_head"
></ui-icon>
</button>
<ui-datepicker
formControlName="pickUpDeadline"
@@ -133,33 +225,66 @@
[disabledDaysOfWeek]="[0]"
[selected]="items[i]?.pickUpDeadline"
saveLabel="Übernehmen"
(save)="changePickupDeadline($event, items[i]); deadlineDatepicker.close()"
(save)="
changePickupDeadline($event, items[i]);
deadlineDatepicker.close()
"
></ui-datepicker>
</ui-form-control>
</ng-template>
<ui-form-control label="Menge" variant="inline" statusLabel="Nicht Änderbar">
<ui-form-control
label="Menge"
variant="inline"
statusLabel="Nicht Änderbar"
>
<input uiInput formControlName="quantity" />
</ui-form-control>
<ui-form-control class="price" label="Preis" [clearable]="true" [suffix]="item.value?.currency" variant="inline" requiredMark=" *">
<ui-form-control
class="price"
label="Preis"
[clearable]="true"
[suffix]="item.value?.currency"
variant="inline"
requiredMark=" *"
>
<input uiInput formControlName="price" />
</ui-form-control>
<ui-form-control label="ISBN/EAN" [clearable]="true" variant="inline" requiredMark=" *">
<ui-form-control
label="ISBN/EAN"
[clearable]="true"
variant="inline"
requiredMark=" *"
>
<input uiInput formControlName="ean" />
</ui-form-control>
<ui-form-control label="Zielfiliale" variant="inline" statusLabel="Nicht Änderbar">
<ui-form-control
label="Zielfiliale"
variant="inline"
statusLabel="Nicht Änderbar"
>
<input uiInput formControlName="targetBranch" />
</ui-form-control>
<ui-form-control label="Lieferant" variant="inline" statusLabel="Nicht Änderbar">
<ui-form-control
label="Lieferant"
variant="inline"
statusLabel="Nicht Änderbar"
>
<input uiInput formControlName="supplier" />
</ui-form-control>
<div class="ssc-wrapper">
<ui-form-control class="ssc" label="Meldenummer" [clearable]="true" variant="inline" requiredMark=" *">
<ui-form-control
class="ssc"
label="Meldenummer"
[clearable]="true"
variant="inline"
requiredMark=" *"
>
<input class="ssc-input" uiInput formControlName="ssc" />
</ui-form-control>
<input class="ssc-text" uiInput formControlName="sscText" />
@@ -174,11 +299,21 @@
<ui-form-control label="MwSt" variant="inline">
<ui-select formControlName="vat">
<ui-select-option *ngFor="let vat of vats$ | async" [label]="vat.name + '%'" [value]="vat.vatType"></ui-select-option>
<ui-select-option
*ngFor="let vat of vats$ | async"
[label]="vat.name + '%'"
[value]="vat.vatType"
></ui-select-option>
</ui-select>
</ui-form-control>
<ui-form-control class="special-comment" label="Anmerkung" [clearable]="true" (cleared)="clearSpecialComment(i)" variant="inline">
<ui-form-control
class="special-comment"
label="Anmerkung"
[clearable]="true"
(cleared)="clearSpecialComment(i)"
variant="inline"
>
<textarea
matInput
cdkTextareaAutosize
@@ -195,7 +330,15 @@
</div>
</div>
<div class="actions">
<button class="cta-close" (click)="navigation.emit({})" type="button">Abbrechen</button>
<button class="cta-save" [disabled]="control.invalid || control.disabled" type="submit">Speichern</button>
<button class="cta-close" (click)="navigation.emit({})" type="button">
Abbrechen
</button>
<button
class="cta-save"
[disabled]="control.invalid || control.disabled"
type="submit"
>
Speichern
</button>
</div>
</form>

View File

@@ -1,3 +1,4 @@
@use "../../../libs/ui/buttons/src/buttons.scss";
@use "../../../libs/ui/input-controls/src/input-controls.scss";
@use "../../../libs/ui/progress-bar/src/lib/progress-bar.scss";
@use '../../../libs/ui/buttons/src/buttons.scss';
@use '../../../libs/ui/datepicker/src/datepicker.scss';
@use '../../../libs/ui/input-controls/src/input-controls.scss';
@use '../../../libs/ui/progress-bar/src/lib/progress-bar.scss';

View File

@@ -0,0 +1,33 @@
import { Meta, StoryObj } from '@storybook/angular';
import { DateRangeValue, RangeDatepickerComponent } from '@isa/ui/datepicker';
interface DatepickerComponentInputs {
value: DateRangeValue;
min: Date;
max: Date;
}
const meta: Meta<RangeDatepickerComponent> = {
title: 'ui/datepicker/Datepicker',
component: RangeDatepickerComponent,
argTypes: {
value: { control: 'object' },
min: { control: 'date' },
max: { control: 'date' },
},
args: {
value: undefined,
min: new Date(2022, 0, 1),
max: new Date(2026, 11, 31),
},
};
export default meta;
type Story = StoryObj<DatepickerComponentInputs>;
export const Default: Story = {
args: {
value: [new Date(), new Date(Date.now() + 5 * 24 * 60 * 60 * 1000)], // Start: today, End: 5 days after today
},
};

View File

@@ -1,6 +1,10 @@
export * from './errors';
export * from './models';
export * from './return-details.service';
export * from './return-details.store';
export * from './return-process-questions';
export * from './return-process.service';
export * from './return-process.store';
export * from './return-search.service';
export * from './return-search.store';
export * from './schemas';

View File

@@ -1,8 +1,10 @@
<ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Email:</ui-item-row-data-label>
<ui-item-row-data-label>Belegdatum:</ui-item-row-data-label>
<ui-item-row-data-value>
<span>{{ communicationDetails().email }}</span>
<span>{{
(receipt().printedDate | date: 'dd.MM.yyyy | hh:mm') + ' Uhr'
}}</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { Buyer, Receipt } from '@isa/oms/data-access';
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Receipt } from '@isa/oms/data-access';
import { ReceiptTypeTranslationPipe } from '@isa/oms/utils/translation';
import { ItemRowDataImports } from '@isa/ui/item-rows';
@Component({
@@ -7,18 +8,8 @@ import { ItemRowDataImports } from '@isa/ui/item-rows';
templateUrl: './return-details-data.component.html',
styleUrls: ['./return-details-data.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ItemRowDataImports, ReceiptTypeTranslationPipe],
imports: [ItemRowDataImports, ReceiptTypeTranslationPipe, DatePipe],
})
export class ReturnDetailsDataComponent {
receipt = input.required<Receipt>();
buyer = computed<Buyer>(() => {
const receipt = this.receipt();
return receipt.buyer;
});
communicationDetails = computed(() => {
const buyer = this.buyer();
return buyer.communicationDetails;
});
}

View File

@@ -2,7 +2,7 @@
<ui-item-row-data-row>
<ui-item-row-data-label>Belegdatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ receipt().printedDate | date }}
{{ (receipt().printedDate | date: 'dd.MM.yyyy | hh:mm') + ' Uhr' }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
@@ -12,7 +12,7 @@
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Vorgangs-ID:</ui-item-row-data-label>
<ui-item-row-data-label>Vorgang-ID:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ receipt().order?.data?.orderNumber }}
</ui-item-row-data-value>
@@ -20,7 +20,9 @@
<ui-item-row-data-row>
<ui-item-row-data-label>Bestelldatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ receipt().order?.data?.orderDate | date }}
{{
(receipt().order?.data?.orderDate | date: 'dd.MM.yyyy | hh:mm') + ' Uhr'
}}
</ui-item-row-data-value>
</ui-item-row-data-row>
@if (receipt().buyer?.address; as address) {

View File

@@ -1,9 +1,23 @@
<ui-toolbar size="small" class="justify-self-stretch">
<div class="isa-text-body-2-bold text-isa-neutral-900">{{ items().length }} Artikel</div>
<div class="isa-text-body-2-bold text-isa-neutral-900">
{{ items().length }} Artikel
</div>
<div class="isa-text-body-2-bold text-isa-neutral-900">
{{ receipt().printedDate | date }}
</div>
<div class="isa-text-body-2-regular text-isa-neutral-900">
{{ receipt().receiptNumber }}
</div>
<div class="flex-grow"></div>
@if (selectableItems().length) {
<button type="button" uiTextButton color="strong" size="small" (click)="selectOrUnselectAll()">
<button
type="button"
uiTextButton
color="strong"
size="small"
(click)="selectOrUnselectAll()"
>
@if (allSelected()) {
Alles abwählen
} @else {

View File

@@ -1,5 +1,12 @@
import { ChangeDetectionStrategy, Component, computed, input, model } from '@angular/core';
import { ReceiptItem } from '@isa/oms/data-access';
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
model,
} from '@angular/core';
import { Receipt, ReceiptItem } from '@isa/oms/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { ToolbarComponent } from '@isa/ui/toolbar';
@@ -9,9 +16,10 @@ import { ToolbarComponent } from '@isa/ui/toolbar';
styleUrls: ['./return-details-order-group.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ToolbarComponent, TextButtonComponent],
imports: [ToolbarComponent, TextButtonComponent, DatePipe],
})
export class ReturnDetailsOrderGroupComponent {
receipt = input.required<Receipt>();
items = input.required<ReceiptItem[]>();
selectedItems = model<ReceiptItem[]>([]);

View File

@@ -24,8 +24,12 @@
</div>
@if (receipt) {
<div class="flex flex-col items-start justify-stretch gap-6 rounded-2xl bg-isa-white px-4 py-6">
<oms-feature-return-details-header [buyer]="receipt.buyer"></oms-feature-return-details-header>
<div
class="flex flex-col items-start justify-stretch gap-6 rounded-2xl bg-isa-white px-4 py-6"
>
<oms-feature-return-details-header
[buyer]="receipt.buyer"
></oms-feature-return-details-header>
@if (showMore()) {
<oms-feature-return-details-order-group-data
@@ -43,7 +47,9 @@
Weniger anzeigen
</button>
} @else {
<oms-feature-return-details-data [receipt]="receipt"></oms-feature-return-details-data>
<oms-feature-return-details-data
[receipt]="receipt"
></oms-feature-return-details-data>
<button
class="-ml-3"
uiTextButton
@@ -58,6 +64,7 @@
}
<div></div>
<oms-feature-return-details-order-group
[receipt]="receipt"
[items]="receiptItems()"
[(selectedItems)]="selectedItems"
></oms-feature-return-details-order-group>

View File

@@ -19,7 +19,7 @@
</div>
}
<div class="flex flex-row gap-4">
<div class="flex flex-row gap-2">
@for (filterInput of filterInputs(); track filterInput.key) {
<filter-input-menu-button [filterInput]="filterInput" (applied)="onSearch()">
</filter-input-menu-button>

View File

@@ -15,7 +15,9 @@ import { FILTER_ON_COMMIT, FILTER_ON_INIT, QUERY_SETTINGS } from './tokens';
@Injectable()
export class FilterService {
#onInit = inject(FILTER_ON_INIT, { optional: true })?.map((fn) => fn(this));
#onCommit = inject(FILTER_ON_COMMIT, { optional: true })?.map((fn) => fn(this));
#onCommit = inject(FILTER_ON_COMMIT, { optional: true })?.map((fn) =>
fn(this),
);
readonly settings = inject(QUERY_SETTINGS);
@@ -37,7 +39,11 @@ export class FilterService {
});
}
setOrderBy(by: string, dir: OrderByDirection | undefined, options?: { commit: boolean }) {
setOrderBy(
by: string,
dir: OrderByDirection | undefined,
options?: { commit: boolean },
) {
const orderByList = this.#state.orderBy().map((o) => {
if (o.by === by && o.dir === dir) {
return { ...o, selected: true };
@@ -52,7 +58,11 @@ export class FilterService {
}
}
setInputTextValue(key: string, value: string | undefined, options?: { commit: boolean }): void {
setInputTextValue(
key: string,
value: string | undefined,
options?: { commit: boolean },
): void {
const inputs = this.#state.inputs().map((input) => {
if (input.key !== key) {
return input;
@@ -74,7 +84,11 @@ export class FilterService {
}
}
setInputCheckboxValue(key: string, selected: string[], options?: { commit: boolean }): void {
setInputCheckboxValue(
key: string,
selected: string[],
options?: { commit: boolean },
): void {
const inputs = this.#state.inputs().map((input) => {
if (input.key !== key) {
return input;
@@ -96,6 +110,33 @@ export class FilterService {
}
}
setInputDateRangeValue(
key: string,
start?: string,
stop?: string,
options?: { commit: boolean },
): void {
const inputs = this.#state.inputs().map((input) => {
if (input.key !== key) {
return input;
}
if (input.type === InputType.DateRange) {
return { ...input, start, stop };
}
console.warn(`Input type not supported: ${input.type}`);
return input;
});
patchState(this.#state, { inputs });
if (options?.commit) {
this.commit();
}
}
/**
* Indicates whether the current state is the default state.
* This computed property checks if the current state is equal to the default state.
@@ -106,8 +147,12 @@ export class FilterService {
});
isDefaultFilterInput(filterInput: FilterInput) {
const currentInputState = this.#state.inputs().find((i) => i.key === filterInput.key);
const defaultInputState = this.defaultState.inputs.find((i) => i.key === filterInput.key);
const currentInputState = this.#state
.inputs()
.find((i) => i.key === filterInput.key);
const defaultInputState = this.defaultState.inputs.find(
(i) => i.key === filterInput.key,
);
return isEqual(currentInputState, defaultInputState);
}
@@ -126,14 +171,20 @@ export class FilterService {
return !input.selected?.length;
}
console.warn(`Input type not supported: ${input.type}`);
if (input.type === InputType.DateRange) {
return !input.start && !input.stop;
}
console.warn(`Input type not supported`);
return true;
});
});
isEmptyFilterInput(filterInput: FilterInput) {
const currentInputState = this.#state.inputs().find((i) => i.key === filterInput.key);
const currentInputState = this.#state
.inputs()
.find((i) => i.key === filterInput.key);
if (currentInputState?.type === InputType.Text) {
return !currentInputState.value;
@@ -143,7 +194,11 @@ export class FilterService {
return !currentInputState.selected?.length;
}
console.warn(`Input type not supported: ${currentInputState?.type}`);
if (currentInputState?.type === InputType.DateRange) {
return !currentInputState.start && !currentInputState.stop;
}
console.warn(`Input type not supported`);
return true;
}
@@ -206,7 +261,9 @@ export class FilterService {
return;
}
const inputIndex = this.#commitdState().inputs.findIndex((i) => i.key === key);
const inputIndex = this.#commitdState().inputs.findIndex(
(i) => i.key === key,
);
if (inputIndex === -1) {
console.warn(`No committed input found with key: ${key}`);
@@ -240,6 +297,10 @@ export class FilterService {
return { ...input, selected: [] };
}
if (input.type === InputType.DateRange) {
return { ...input, start: undefined, stop: undefined };
}
return input;
});
@@ -330,6 +391,15 @@ export class FilterService {
result[input.key] = input.selected.join(';');
}
break;
case InputType.DateRange:
if (input.start && input.stop) {
result[input.key] = `"${input.start}"-"${input.stop}"`;
} else if (input.start) {
result[input.key] = `"${input.start}"-`;
} else if (input.stop) {
result[input.key] = `-"${input.stop}"`;
}
break;
}
}
@@ -344,7 +414,9 @@ export class FilterService {
isQueryParamsEqual(params: Record<string, string>): boolean {
const currentParams = this.queryParams();
return this.queryParamKeys().every((key) => params[key] === currentParams[key]);
return this.queryParamKeys().every(
(key) => params[key] === currentParams[key],
);
}
queryParamKeys = computed(() => {
@@ -371,6 +443,14 @@ export class FilterService {
acc[input.key] = input.value || '';
} else if (input.type === InputType.Checkbox) {
acc[input.key] = input.selected?.join(';') || '';
} else if (input.type === InputType.DateRange) {
if (input.start && input.stop) {
acc[input.key] = `"${input.start}"-"${input.stop}"`;
} else if (input.start) {
acc[input.key] = `"${input.start}"-`;
} else if (input.stop) {
acc[input.key] = `-"${input.stop}"`;
}
}
return acc;
}, {}),
@@ -379,6 +459,14 @@ export class FilterService {
acc[input.key] = input.value || '';
} else if (input.type === InputType.Checkbox) {
acc[input.key] = input.selected?.join(';') || '';
} else if (input.type === InputType.DateRange) {
if (input.start && input.stop) {
acc[input.key] = `"${input.start}"-"${input.stop}"`;
} else if (input.start) {
acc[input.key] = `"${input.start}"-`;
} else if (input.stop) {
acc[input.key] = `-"${input.stop}"`;
}
}
return acc;
}, {}),
@@ -395,13 +483,18 @@ export class FilterService {
});
});
parseQueryParams(params: Record<string, string>, options?: { commit: boolean }): void {
parseQueryParams(
params: Record<string, string>,
options?: { commit: boolean },
): void {
this.reset();
for (const key in params) {
if (key === 'orderBy') {
const [by, dir] = params[key].split(':');
const orderBy = this.orderBy().some((o) => o.by === by && o.dir === dir);
const orderBy = this.orderBy().some(
(o) => o.by === by && o.dir === dir,
);
if (orderBy) {
console.warn(`OrderBy already exists: ${by}:${dir}`);
@@ -419,6 +512,14 @@ export class FilterService {
case InputType.Checkbox:
this.setInputCheckboxValue(key, params[key].split(';'));
break;
case InputType.DateRange: {
const decoded = decodeURIComponent(params[key]);
const [startRaw, stopRaw] = decoded.split('-"');
const start = startRaw?.replace(/"/g, '') || undefined;
const stop = stopRaw?.replace(/"/g, '') || undefined;
this.setInputDateRangeValue(key, start, stop);
break;
}
default:
console.warn(`Input type not supported: ${inputType}`);
break;

View File

@@ -24,5 +24,7 @@ export function dateRangeFilterInputMapping(
type: InputType.DateRange,
start: input.options?.values?.[0]?.value,
stop: input.options?.values?.[1]?.value,
minStart: input.options?.values?.[0].minValue,
maxStart: input.options?.values?.[0].maxValue,
});
}

View File

@@ -22,12 +22,24 @@ export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({
.describe(
'ISO date string representing the beginning of the date range. Optional if only an end date is needed.',
),
minStart: z
.string()
.optional()
.describe(
'ISO date string representing the minimum start date of the range. Optional if only an end date is needed.',
),
stop: z
.string()
.optional()
.describe(
'ISO date string representing the end of the date range. Optional if only a start date is needed.',
),
maxStop: z
.string()
.optional()
.describe(
'ISO date string representing the maximum end date of the range. Optional if only a start date is needed.',
),
}).describe('DateRangeFilterInput');
export type DateRangeFilterInput = z.infer<typeof DateRangeFilterInputSchema>;

View File

@@ -1 +0,0 @@
<h1>📅📅📅</h1>

View File

@@ -1,3 +0,0 @@
.filter-datepicker-input {
@apply flex items-center justify-center bg-isa-white w-[18.375rem] h-[29.5rem] rounded-[1.25rem] shadow-[0px_0px_16px_0px_rgba(0,0,0,0.15)];
}

View File

@@ -1,37 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
effect,
input,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
@Component({
selector: 'filter-datepicker-input',
templateUrl: './datepicker-input.component.html',
styleUrls: ['./datepicker-input.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
host: {
'[class]': "['filter-datepicker-input']",
},
})
export class DatepickerInputComponent {
inputKey = input.required<string>();
datepicker = new FormControl({});
valueChanges = toSignal(this.datepicker.valueChanges);
constructor() {
effect(() => {
this.valueChanges();
untracked(() => {
console.log({ startTest: '2021-01-01', stopTest: '2021-12-31' });
});
});
}
}

View File

@@ -1 +0,0 @@
export * from './datepicker-input.component';

View File

@@ -0,0 +1,8 @@
@let inp = input();
@if (inp) {
<ui-range-datepicker
[formControl]="datepicker"
[min]="datepickerMin()"
[max]="datepickerMax()"
></ui-range-datepicker>
}

View File

@@ -0,0 +1,3 @@
.filter-datepicker-range-input {
@apply inline-block;
}

View File

@@ -0,0 +1,94 @@
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
effect,
inject,
input,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { DateRangeValue, RangeDatepickerComponent } from '@isa/ui/datepicker';
import { DateRangeFilterInput, FilterService } from '../../core';
import { InputType } from '../../types';
@Component({
selector: 'filter-datepicker-range-input',
templateUrl: './datepicker-range-input.component.html',
styleUrls: ['./datepicker-range-input.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [RangeDatepickerComponent, ReactiveFormsModule],
host: { '[class]': "['filter-datepicker-range-input']" },
})
export class DatepickerRangeInputComponent {
readonly filterService = inject(FilterService);
inputKey = input.required<string>();
datepicker = new FormControl<DateRangeValue | undefined>(undefined);
valueChanges = toSignal(this.datepicker.valueChanges);
input = computed<DateRangeFilterInput>(() => {
const inputs = this.filterService.inputs();
const input = inputs.find(
(input) =>
input.key === this.inputKey() && input.type === InputType.DateRange,
) as DateRangeFilterInput;
if (!input) {
throw new Error(`Input not found for key: ${this.inputKey()}`);
}
return input;
});
datepickerMin = computed<Date | undefined>(() => {
const inp = this.input();
return inp.minStart ? new Date(inp.minStart) : undefined;
});
datepickerMax = computed<Date | undefined>(() => {
const inp = this.input();
return inp.maxStop ? new Date(inp.maxStop) : undefined;
});
constructor() {
effect(() => {
const input = this.input();
const startDate = input.start ? new Date(input.start) : undefined;
const stopDate = input.stop ? new Date(input.stop) : undefined;
this.datepicker.patchValue([startDate, stopDate]);
this.datepicker.updateValueAndValidity();
});
effect(() => {
this.valueChanges();
untracked(() => {
const startDate = this.datepicker?.value?.[0];
const stopDate = this.datepicker?.value?.[1];
if (!startDate && !stopDate) {
return;
}
const start = startDate?.toISOString();
const stop = stopDate?.toISOString();
const controlEqualsInput =
this.input().start === start && this.input().stop === stop;
if (!controlEqualsInput) {
this.filterService.setInputDateRangeValue(
this.inputKey(),
start,
stop,
);
}
});
});
}
}

View File

@@ -0,0 +1 @@
export * from './datepicker-range-input.component';

View File

@@ -1,4 +1,4 @@
export * from './search-bar-input';
export * from './checkbox-input';
export * from './datepicker-input';
export * from './datepicker-range-input';
export * from './input-renderer';

View File

@@ -1,9 +1,11 @@
@switch (filterInput().type) {
@case (InputType.Checkbox) {
<filter-checkbox-input [inputKey]="filterInput().key"> </filter-checkbox-input>
<filter-checkbox-input [inputKey]="filterInput().key">
</filter-checkbox-input>
}
@case (InputType.DateRange) {
<filter-datepicker-input [inputKey]="filterInput().key"> </filter-datepicker-input>
<filter-datepicker-range-input [inputKey]="filterInput().key">
</filter-datepicker-range-input>
}
@default {
<div class="text-isa-accent-red isa-text-body-1-bold">

View File

@@ -5,7 +5,7 @@ import {
ViewEncapsulation,
} from '@angular/core';
import { CheckboxInputComponent } from '../checkbox-input';
import { DatepickerInputComponent } from '../datepicker-input';
import { DatepickerRangeInputComponent } from '../datepicker-range-input';
import { FilterInput } from '../../core';
import { InputType } from '../../types';
@@ -16,7 +16,7 @@ import { InputType } from '../../types';
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [CheckboxInputComponent, DatepickerInputComponent],
imports: [CheckboxInputComponent, DatepickerRangeInputComponent],
host: {
'[class]': "['filter-input-renderer']",
},

View File

@@ -2,6 +2,6 @@
@apply inline-flex flex-col;
@apply bg-isa-white;
@apply rounded-[1.25rem];
@apply w-[14.3125rem] max-h-[33.5rem];
@apply min-w-[14.3125rem] max-w-[18.375rem] max-h-[33.5rem];
@apply shadow-overlay;
}

View File

@@ -0,0 +1,7 @@
# ui-datepicker
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test ui-datepicker` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ui',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ui',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,21 @@
export default {
displayName: 'ui-datepicker',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/ui/datepicker',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -0,0 +1,20 @@
{
"name": "ui-datepicker",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/datepicker/src",
"prefix": "ui",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ui/datepicker/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,6 @@
@use 'lib/range-datepicker';
@use 'lib/calendar-body';
@use 'lib/month-year-body';
@use 'lib/selected-date';
@use 'lib/selected-month-year';
@use 'lib/selected-range';

View File

@@ -0,0 +1,5 @@
export * from './lib/range-datepicker.component';
export * from './lib/tokens';
export * from './lib/datepicker-base';
export * from './lib/range-datepicker';
export * from './lib/types';

View File

@@ -0,0 +1,98 @@
.ui-calendar-body {
@apply flex flex-col w-full pt-8 pb-6 gap-4;
}
.ui-calendar-body__cell-container {
@apply grid grid-cols-[repeat(7,2.375rem)] justify-center;
}
.ui-calendar-body__cell {
@apply relative w-full h-full flex items-center justify-center text-isa-neutral-900 isa-text-body-1-semibold p-2 leading-4 aspect-square;
z-index: 10;
&.day-label {
@apply text-isa-neutral-400;
}
&.today {
@apply text-isa-accent-blue;
&:hover {
@apply text-isa-secondary-700;
}
}
&.in-range:not(.selected) {
@apply bg-isa-secondary-200;
}
&.selected {
@apply rounded-[2.5rem] bg-isa-accent-blue text-isa-white;
z-index: 10;
&:hover {
@apply bg-isa-secondary-700 text-isa-white;
}
// Styling für Date-Range
&.start::before,
&.end::before {
content: '';
@apply absolute inset-0 bg-isa-secondary-200;
z-index: -1;
width: 50%;
}
// Styling für das selektierte Start Element von Date-Range
&.start::after {
content: '';
@apply absolute inset-0 bg-isa-secondary-200;
z-index: -2;
left: 50%;
}
&.start::before {
@apply bg-isa-accent-blue rounded-r-[2.5rem];
left: 50%;
z-index: -1;
&:hover {
@apply bg-isa-secondary-700;
}
}
// Styling für das selektierte Bis Element von Date-Range
&.end::after {
content: '';
@apply absolute inset-0 bg-isa-secondary-200;
z-index: -2;
right: 50%;
}
&.end::before {
@apply bg-isa-accent-blue rounded-l-[2.5rem];
right: 50%;
z-index: -1;
&:hover {
@apply bg-isa-secondary-700;
}
}
}
&:disabled {
@apply text-isa-neutral-200 cursor-not-allowed;
}
&:hover:not(.day-label):not(.selected):not(.today):not(.in-range):not(
:disabled
) {
@apply rounded-[2.5rem] bg-isa-secondary-100;
}
}
.ui-calendar-body__reset-cta {
@apply py-2 text-isa-neutral-900 underline isa-text-body-2-bold;
&:disabled {
@apply text-isa-neutral-400;
}
}

View File

@@ -0,0 +1,23 @@
.ui-month-year-body {
@apply flex flex-col w-full gap-2;
}
.ui-month-year-body__list {
@apply min-h-80 max-h-80 flex flex-col overflow-hidden overflow-y-scroll px-1;
}
.ui-month-year-body__list-element {
@apply h-12 rounded-2xl flex px-6 py-[0.88rem] items-center justify-between isa-text-body-2-bold text-isa-neutral-700;
&.selected {
@apply bg-isa-neutral-200 text-isa-neutral-900;
}
}
.ui-month-body__actions {
@apply flex flex-row items-center justify-center gap-2 my-3;
button {
@apply w-[8.125rem] max-w-[8.125rem] min-w-[8.125rem];
}
}

View File

@@ -0,0 +1,3 @@
.ui-range-datepicker {
@apply inline-block w-[18.375rem] font-sans;
}

View File

View File

@@ -0,0 +1,19 @@
.ui-selected-month-year {
@apply w-full h-[4.5rem] px-4 flex flex-row items-center justify-between border-b-[0.0625rem] border-isa-neutral-400;
}
.ui-selected-month-year__month-cta {
@apply px-4 py-2 flex flex-row gap-2 items-center justify-center text-isa-neutral-900 isa-text-body-2-bold border border-solid border-transparent;
&.selected {
@apply border border-solid border-isa-neutral-900 rounded-[3.125rem];
}
}
.ui-selected-month-year__year-cta {
@apply px-4 py-2 flex flex-row gap-2 items-center justify-center text-isa-neutral-900 isa-text-body-2-bold border border-solid border-transparent;
&.selected {
@apply border border-solid border-isa-neutral-900 rounded-[3.125rem];
}
}

View File

@@ -0,0 +1,36 @@
.ui-selected-range {
@apply w-full h-[4.5rem] flex items-end justify-center border-b-[0.0625rem] border-isa-neutral-400 pb-[0.75rem];
}
.ui-selected-range__input-wrapper {
@apply relative flex flex-col gap-1;
label {
@apply text-isa-neutral-900 text-[0.5rem] font-bold leading-[0.625rem] uppercase;
}
input {
@apply w-[6.25rem] isa-text-body-1-bold outline-none;
}
input::placeholder {
@apply text-isa-neutral-500 isa-text-body-1-regular;
}
.ui-selected-range__start-focus-indicator,
.ui-selected-range__stop-focus-indicator {
@apply hidden absolute -bottom-[0.77rem] w-[5.125rem] h-[0.25rem] bg-isa-neutral-700 rounded-[3.125rem];
}
&:has(input#start:focus) {
.ui-selected-range__start-focus-indicator {
@apply block;
}
}
&:has(input#stop:focus) {
.ui-selected-range__stop-focus-indicator {
@apply block;
}
}
}

View File

@@ -0,0 +1,62 @@
import {
createDirectiveFactory,
SpectatorDirective,
} from '@ngneat/spectator/jest';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { CalendarBodyCellDirective } from './calendar-body-cell.directive';
import { startOfDay } from 'date-fns';
import { RangeDatepicker } from '../../range-datepicker';
describe('CalendarBodyCellDirective', () => {
let spectator: SpectatorDirective<CalendarBodyCellDirective>;
const today = new Date();
const todayStart = startOfDay(today);
const dayBeforeYesterday = new Date(
todayStart.getFullYear(),
todayStart.getMonth(),
todayStart.getDate() - 2,
);
const datepickerMock = {
value: jest.fn(),
min: jest.fn(),
max: jest.fn(),
range: jest.fn(),
setDate: jest.fn(),
setDateRange: jest.fn(),
};
const createDirective = createDirectiveFactory({
directive: CalendarBodyCellDirective,
providers: [{ provide: RangeDatepicker, useValue: datepickerMock }],
schemas: [NO_ERRORS_SCHEMA],
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should mark isToday true when cell date equals today', () => {
spectator = createDirective(
`<div uiCalendarBodyCell="${todayStart.toISOString()}"></div>`,
);
expect(spectator.directive.isToday()).toBe(true);
});
it('should mark isToday false when cell date is not today', () => {
spectator = createDirective(
`<div uiCalendarBodyCell="${dayBeforeYesterday.toISOString()}"></div>`,
);
expect(spectator.directive.isToday()).toBe(false);
});
it('should return false for isDisabled when cell date is within min and max', () => {
spectator = createDirective(
`<div uiCalendarBodyCell="${todayStart.toISOString()}"></div>`,
);
datepickerMock.min.mockReturnValue(undefined);
datepickerMock.max.mockReturnValue(undefined);
spectator.detectChanges();
expect(spectator.directive.isDisabled()).toBe(false);
});
});

View File

@@ -0,0 +1,118 @@
import { computed, Directive, input } from '@angular/core';
import {
isAfter,
isBefore,
isDate,
isSameDay,
isToday,
isWithinInterval,
startOfDay,
} from 'date-fns';
import { injectDatepicker } from '../../inject-datepicker';
@Directive({
selector: '[uiCalendarBodyCell]',
host: {
'[class.selected]': 'isSelected()',
'[class.today]': 'isToday()',
'[disabled]': 'isDisabled()',
'[class.start]': "isDateInRange() === 'start'",
'[class.end]': "isDateInRange() === 'end'",
'[class.in-range]': "isDateInRange() === 'in-range'",
'(click)': 'setValue()',
},
})
export class CalendarBodyCellDirective {
/**
* The injected Datepicker instance.
*/
datepicker = injectDatepicker();
/**
* A required input property representing a day as a Date object.
*/
day = input.required<Date>({ alias: 'uiCalendarBodyCell' });
/**
* Computed property that returns true if the current day is today.
*/
isToday = computed<boolean>(() => isToday(this.day()));
/**
* Computed property that determines if the current day should be disabled.
* Returns true if the day is before the minimum allowed date or after the maximum.
*/
isDisabled = computed<boolean>(() => {
const currentDay = this.day();
const min = this.datepicker.min();
const max = this.datepicker.max();
if (min && isBefore(currentDay, startOfDay(min))) {
return true;
}
if (max && isAfter(currentDay, startOfDay(max))) {
return true;
}
return false;
});
/**
* Computed property that indicates whether the current day is selected.
* Checks if the day matches the selected date or is part of the selected range.
*/
isSelected = computed<boolean>(() => {
const currentDay = this.day();
const value = this.datepicker.value();
if (Array.isArray(value)) {
return value.some((date) => isDate(date) && isSameDay(currentDay, date));
}
return false;
});
/**
* Computed property that determines the role of the current day in a date range.
*
* Returns:
* - 'start' if the day is the start of the range and the range spans multiple days,
* - 'end' if the day is the end of the range and the range spans multiple days,
* - 'in-range' if the day lies between the start and end,
* - An empty string if the day is outside the range or if start and end are the same.
*/
isDateInRange = computed<'start' | 'end' | 'in-range' | ''>(() => {
const currentDay = this.day();
const value = this.datepicker.value();
const [start, end] = value ?? [undefined, undefined];
if (start && end) {
if (isSameDay(currentDay, start) && !isSameDay(start, end)) {
return 'start';
}
if (isSameDay(currentDay, end) && !isSameDay(start, end)) {
return 'end';
}
if (isWithinInterval(currentDay, { start, end })) {
return 'in-range';
}
}
return '';
});
/**
* Sets the date selection based on the current day.
*
* If the datepicker is in range mode, then sets the start or end of the range.
* Otherwise, sets the selected date to the current day.
*/
setValue(): void {
const currentDay = this.day();
this.datepicker.setDateRange(currentDay);
}
}

View File

@@ -0,0 +1,55 @@
<div
class="flex flex-row justify-between items-center pl-[1.475rem] pr-[1.1rem] mb-1"
>
<button
type="button"
(click)="viewState.displayedView.set(DatepickerView.Month)"
class="flex flex-row items-center gap-2"
>
<span class="isa-text-body-1-bold-big text-isa-neutral-900">
{{ viewState.displayedDate() | date: 'MMMM YYYY' }}
</span>
<ng-icon size="1.25rem" name="isaActionChevronDown"></ng-icon>
</button>
<div class="flex flex-row gap-4 items-center">
<button type="button" (click)="previous()" class="flex items-center">
<ng-icon size="1.5rem" name="isaActionChevronLeft"></ng-icon>
</button>
<button type="button" (click)="next()" class="flex items-center">
<ng-icon size="1.5rem" name="isaActionChevronRight"></ng-icon>
</button>
</div>
</div>
<div class="flex flex-col justfy-center pl-[0.94rem] pr-[0.81rem]">
<div class="ui-calendar-body__cell-container">
@for (dayNameShort of daysOfWeek(); let i = $index; track i) {
<div class="ui-calendar-body__cell day-label">
{{ dayNameShort }}
</div>
}
</div>
<div class="ui-calendar-body__cell-container">
@for (day of calendarDays(); let i = $index; track i) {
<button
[uiCalendarBodyCell]="day"
type="button"
class="ui-calendar-body__cell"
>
{{ day | date: 'd' }}
</button>
}
</div>
</div>
<button
class="ui-calendar-body__reset-cta"
type="button"
[disabled]="!hasValue()"
(click)="datepicker.setValue()"
>
Entfernen
</button>

View File

@@ -0,0 +1,223 @@
import { Directive } from '@angular/core';
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { DatePipe } from '@angular/common';
import { NgIconComponent } from '@ng-icons/core';
import { CalendarBodyComponent } from './calendar-body.component';
import { DatepickerViewState } from '../../datepicker-view.state';
import {
eachDayOfInterval,
endOfMonth,
endOfWeek,
startOfMonth,
startOfWeek,
} from 'date-fns';
import { RangeDatepicker } from '../../range-datepicker';
// Stub directive override to prevent evaluation of the real computed property
@Directive({
selector: '[uiCalendarBodyCell]',
})
// eslint-disable-next-line @angular-eslint/directive-class-suffix
class CalendarBodyCellDirectiveStub {
// Provide a default getter to avoid errors in host bindings.
get isDateInRange() {
return false;
}
}
describe('CalendarBodyComponent', () => {
let spectator: Spectator<CalendarBodyComponent>;
const initialDisplayedDate = new Date(2024, 5, 15); // June 15, 2024
const datepickerMock = {
value: jest.fn(),
min: jest.fn(),
max: jest.fn(),
range: jest.fn(), // used only in the directives; we do not test these cases here
};
const displayedDateSetSpy = jest.fn();
const displayedDateFn = jest.fn(() => initialDisplayedDate);
const displayedDateSignal = Object.assign(displayedDateFn, {
set: displayedDateSetSpy,
});
const viewStateMock = {
displayedDate: displayedDateSignal,
};
const createComponent = createComponentFactory({
component: CalendarBodyComponent,
schemas: [NO_ERRORS_SCHEMA],
// Use stub to prevent executing the original CalendarBodyCellDirective computed code.
imports: [DatePipe, NgIconComponent, CalendarBodyCellDirectiveStub],
providers: [
{ provide: RangeDatepicker, useValue: datepickerMock },
{ provide: DatepickerViewState, useValue: viewStateMock },
],
});
beforeEach(() => {
datepickerMock.min.mockReturnValue(undefined);
datepickerMock.max.mockReturnValue(undefined);
datepickerMock.range.mockReturnValue(undefined);
// Default: return null to avoid unintended destructuring
datepickerMock.value.mockReturnValue(null);
jest.clearAllMocks();
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
// Additional tests covering robust scenarios
describe('selectedDates computed property', () => {
it('should return an array with a single valid date when a singular date is provided', () => {
const validDate = new Date(2024, 5, 20);
datepickerMock.value.mockReturnValue([validDate]);
spectator = createComponent();
expect(spectator.component.selectedDates()).toEqual([validDate]);
});
it('should filter out invalid dates when an array is provided', () => {
const validDate1 = new Date(2024, 5, 20);
const validDate2 = new Date(2024, 5, 25);
const invalidDate = 'invalid date' as unknown as Date;
datepickerMock.value.mockReturnValue([
validDate1,
invalidDate,
validDate2,
]);
spectator = createComponent();
expect(spectator.component.selectedDates()).toEqual([
validDate1,
validDate2,
]);
});
it('should return an empty array when no date is provided', () => {
datepickerMock.value.mockReturnValue(null);
spectator = createComponent();
expect(spectator.component.selectedDates()).toEqual([]);
});
it('should return an array of valid dates when all dates in array are valid', () => {
const validDate1 = new Date(2024, 5, 10);
const validDate2 = new Date(2024, 5, 15);
datepickerMock.value.mockReturnValue([validDate1, validDate2]);
spectator = createComponent();
expect(spectator.component.selectedDates()).toEqual([
validDate1,
validDate2,
]);
});
});
describe('daysOfWeek computed property', () => {
it('should return an array of exactly seven abbreviated day names', () => {
const result = spectator.component.daysOfWeek();
expect(result).toHaveLength(7);
result.forEach((letter) => {
expect(letter).toMatch(/^[A-Z]$/);
});
});
});
describe('calendarDays computed signal', () => {
it('should compute the correct interval of days for the displayed month', () => {
const { calendarDays } = spectator.component;
const start = startOfWeek(startOfMonth(initialDisplayedDate), {
weekStartsOn: 1,
});
const end = endOfWeek(endOfMonth(initialDisplayedDate), {
weekStartsOn: 1,
});
const expectedDays = eachDayOfInterval({ start, end });
expect(calendarDays()).toEqual(expectedDays);
});
});
describe('hasValue computed property', () => {
it('should return true for a valid singular date', () => {
const validDate = new Date(2024, 5, 20);
datepickerMock.value.mockReturnValue([validDate]);
spectator = createComponent();
expect(spectator.component.hasValue()).toBe(true);
});
it('should return true if at least one valid date exists in an array', () => {
const validDate = new Date(2024, 5, 20);
datepickerMock.value.mockReturnValue([null, validDate]);
spectator = createComponent();
expect(spectator.component.hasValue()).toBe(true);
});
it('should return false when no valid date exists', () => {
datepickerMock.value.mockReturnValue(undefined);
spectator = createComponent();
expect(spectator.component.hasValue()).toBe(false);
});
});
describe('previous() method', () => {
it('should update displayedDate to the previous month', () => {
spectator.component.previous();
const currentDate = displayedDateFn();
const expectedDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() - 1,
);
expect(displayedDateSetSpy).toHaveBeenCalledWith(expectedDate);
});
});
describe('next() method', () => {
it('should update displayedDate to the next month', () => {
spectator.component.next();
const currentDate = displayedDateFn();
const expectedDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
);
expect(displayedDateSetSpy).toHaveBeenCalledWith(expectedDate);
});
});
// Robustness tests for computed properties under unexpected inputs
describe('robustness of computed properties', () => {
it('should handle when datepicker value is an empty array', () => {
datepickerMock.value.mockReturnValue([]);
spectator = createComponent();
expect(spectator.component.selectedDates()).toEqual([]);
expect(spectator.component.hasValue()).toBe(false);
});
it('should handle non-date values gracefully in selectedDates', () => {
datepickerMock.value.mockReturnValue(['foo', 'bar'] as unknown as Date);
spectator = createComponent();
expect(spectator.component.selectedDates()).toEqual([]);
expect(spectator.component.hasValue()).toBe(false);
});
});
// Additional tests for edge cases
describe('edge case tests', () => {
it('should not throw when datepicker.value returns a valid array structure for the directive', () => {
// For the directive computed property (stubbed), we set value as an array of two valid dates.
const startDate = new Date(2024, 5, 1);
const endDate = new Date(2024, 5, 30);
datepickerMock.value.mockReturnValue([startDate, endDate]);
spectator = createComponent();
// These tests focus on component computed properties, the stub prevents errors in the directive.
expect(() => spectator.detectChanges()).not.toThrow();
});
it('should not throw when datepicker.value returns a singular date for component computed properties', () => {
const validDate = new Date(2024, 5, 20);
datepickerMock.value.mockReturnValue([validDate]);
spectator = createComponent();
expect(() => spectator.detectChanges()).not.toThrow();
});
});
});

View File

@@ -0,0 +1,150 @@
import {
ChangeDetectionStrategy,
Component,
computed,
Signal,
inject,
} from '@angular/core';
import { DatePipe } from '@angular/common';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
isaActionChevronDown,
isaActionChevronLeft,
isaActionChevronRight,
} from '@isa/icons';
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isDate,
isValid,
parse,
} from 'date-fns';
import { CalendarBodyCellDirective } from './calendar-body-cell.directive';
import { DatepickerViewState } from '../../datepicker-view.state';
import { DatepickerView } from '../../types/datepicker-view.type';
import { DaysOfWeek } from '../../types/days-of-week.type';
import { injectDatepicker } from '../../inject-datepicker';
import { enUS } from 'date-fns/locale';
/**
* Component that renders the calendar body for the datepicker.
* It computes the calendar days, selected dates, and handles navigation
* between months.
*/
@Component({
selector: 'ui-calendar-body',
templateUrl: 'calendar-body.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': "['ui-calendar-body']",
},
imports: [DatePipe, NgIconComponent, CalendarBodyCellDirective],
providers: [
provideIcons({
isaActionChevronLeft,
isaActionChevronRight,
isaActionChevronDown,
}),
],
})
export class CalendarBodyComponent {
/**
* The injected Datepicker instance.
*/
datepicker = injectDatepicker();
/**
* The state of the Datepicker view.
*/
viewState = inject(DatepickerViewState);
/**
* An alias to the DatepickerView constant.
*/
DatepickerView = DatepickerView;
/**
* A computed property that returns the selected dates from the Datepicker.
* If the value is an array, only valid Date objects are returned.
*/
selectedDates = computed(() => {
const selected = this.datepicker.value();
if (!selected) return [];
if (Array.isArray(selected)) {
return selected.filter((date) => isDate(date));
}
return [selected];
});
/**
* Computes an array of abbreviated day names (first letter in uppercase) based on the DaysOfWeek enum.
*
* It iterates over the values of DaysOfWeek, parses each full day name using the 'EEEE' pattern,
* and then formats the parsed date to extract the first character in uppercase.
*
* @returns {string[]} The array of abbreviated day names.
*/
daysOfWeek = computed(() => {
const dates: string[] = [];
for (const day of Object.values(DaysOfWeek)) {
const parsed = parse(day, 'EEEE', new Date(), { locale: enUS });
dates.push(format(parsed, 'EEEE').charAt(0).toUpperCase());
}
return dates;
});
/**
* A signal that computes the array of days to be displayed in the calendar.
* The interval is determined based on the displayed month and week boundaries.
*/
calendarDays: Signal<Date[]> = computed(() => {
const selectedMonth = this.viewState.displayedDate();
const start = startOfWeek(startOfMonth(selectedMonth), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(selectedMonth), { weekStartsOn: 1 });
return eachDayOfInterval({ start, end });
});
/**
* A computed property that indicates whether the Datepicker has a valid value.
* Checks both singular and array values to ensure at least one valid date exists.
*/
hasValue = computed(() => {
const value = this.datepicker.value();
if (!value) return false;
if (Array.isArray(value)) {
return value.some((date) => !!date && isValid(date));
}
return !!value && isValid(value);
});
/**
* Navigates to the previous month by updating the displayed date.
*/
previous() {
const date = this.viewState.displayedDate();
this.viewState.displayedDate.set(
new Date(date.getFullYear(), date.getMonth() - 1),
);
}
/**
* Navigates to the next month by updating the displayed date.
*/
next() {
const date = this.viewState.displayedDate();
this.viewState.displayedDate.set(
new Date(date.getFullYear(), date.getMonth() + 1),
);
}
}

View File

@@ -0,0 +1,54 @@
<ul class="ui-month-year-body__list">
@if (viewState.displayedView() === 'month') {
@for (month of months(); track month.label) {
<button
(click)="updateMonth(month.value)"
type="button"
class="ui-month-year-body__list-element"
[class.selected]="isSelectedMonth(month.value)"
>
<span>{{ month.label }}</span>
@if (isSelectedMonth(month.value)) {
<ng-icon size="1.5rem" name="isaActionCheck"></ng-icon>
}
</button>
}
}
@if (viewState.displayedView() === 'year') {
@for (year of years(); track year) {
<button
(click)="updateYear(year.value)"
type="button"
class="ui-month-year-body__list-element"
[class.selected]="isSelectedYear(year.value)"
>
<span>{{ year.label }}</span>
@if (isSelectedYear(year.value)) {
<ng-icon size="1.5rem" name="isaActionCheck"></ng-icon>
}
</button>
}
}
</ul>
<div class="ui-month-body__actions">
<button
type="button"
uiButton
(click)="rollbackDisplayedMonthYear(); viewState.displayedView.set('day')"
color="secondary"
>
Verlassen
</button>
<button
type="button"
uiButton
[disabled]="canSave()"
color="primary"
(click)="viewState.displayedView.set('day')"
>
Speichern
</button>
</div>

View File

@@ -0,0 +1,158 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { MonthYearBodyComponent } from './month-year-body.component';
import { DatepickerViewState } from '../../datepicker-view.state';
import { format, getMonth, getYear, startOfMonth } from 'date-fns';
import { RangeDatepicker } from '../../range-datepicker';
describe('MonthYearBodyComponent', () => {
let spectator: Spectator<MonthYearBodyComponent>;
const initialDisplayedDate = new Date(2024, 5, 15); // June 15, 2024
const minDate = new Date(2024, 0, 1); // January 1, 2024
const maxDate = new Date(2024, 11, 31); // December 31, 2024
const datepickerMock = {
min: jest.fn().mockReturnValue(minDate),
max: jest.fn().mockReturnValue(maxDate),
};
const displayedDateSetSpy = jest.fn();
// Create a function that returns the initial date.
const displayedDateFunc = jest.fn(() => initialDisplayedDate);
// Extend the function with a 'set' property using Object.assign.
const displayedDateExtended = Object.assign(displayedDateFunc, {
set: displayedDateSetSpy,
});
const viewStateMock = {
displayedDate: displayedDateExtended,
displayedView: jest.fn(() => 'expectedValue'), // Mock function returning the expected value.
};
const createComponent = createComponentFactory({
component: MonthYearBodyComponent,
providers: [
{ provide: RangeDatepicker, useValue: datepickerMock },
{ provide: DatepickerViewState, useValue: viewStateMock },
],
});
beforeEach(() => {
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should compute the current displayed month and year from viewState', () => {
const comp = spectator.component;
expect(comp.currentDisplayedMonth).toBe(getMonth(initialDisplayedDate));
expect(comp.currentDisplayedYear).toBe(getYear(initialDisplayedDate));
});
it('should compute months array filtering out dates outside min and max bounds', () => {
const comp = spectator.component;
const months = comp.months();
expect(months).toHaveLength(12);
months.forEach((month, index) => {
// Create a date for comparison.
const date = new Date(comp.currentDisplayedYear, index, 1);
const beforeMin = minDate && date < startOfMonth(minDate);
const afterMax = maxDate && date > startOfMonth(maxDate);
if (beforeMin || afterMax) {
expect(month).toBeNull();
} else {
expect(month).toEqual({
label: format(date, 'LLLL'),
value: date,
});
}
});
});
it('should compute years array based on min and max bounds from Datepicker', () => {
const comp = spectator.component;
const yearsArray = comp.years();
const expectedMinYear = getYear(minDate);
const expectedMaxYear = getYear(maxDate);
const expectedLength = expectedMaxYear - expectedMinYear + 1;
expect(yearsArray).toHaveLength(expectedLength);
// Ensure years are ordered descending from maxYear to minYear.
yearsArray.forEach((yearObj, idx) => {
const expectedYear = expectedMaxYear - idx;
expect(yearObj.label).toBe(expectedYear.toString());
expect(getYear(yearObj.value)).toBe(expectedYear);
});
});
it('should compute canSave as true when current displayed month/year match viewState date', () => {
const comp = spectator.component;
// Because currentDisplayedMonth/year are derived from viewState.displayedDate,
// they match, so canSave should be true.
expect(comp.canSave()).toBe(true);
});
it('should update month while keeping year intact when updateMonth is called', () => {
const comp = spectator.component;
const newDate = new Date(2024, 8, 1); // September 1, 2024
comp.updateMonth(newDate);
// Year should remain as in initialDisplayedDate
const expectedDate = new Date(
getYear(initialDisplayedDate),
getMonth(newDate),
);
expect(displayedDateSetSpy).toHaveBeenCalledWith(expectedDate);
});
it('should update year while keeping month intact when updateYear is called', () => {
const comp = spectator.component;
const newDate = new Date(2026, 0, 1); // Year 2026, month value ignored
comp.updateYear(newDate);
// Month should remain as in initialDisplayedDate
const expectedDate = new Date(
getYear(newDate),
getMonth(initialDisplayedDate),
);
expect(displayedDateSetSpy).toHaveBeenCalledWith(expectedDate);
});
it('should rollback displayed month and year to original values', () => {
const comp = spectator.component;
comp.rollbackDisplayedMonthYear();
const expectedDate = new Date(
comp.currentDisplayedYear,
comp.currentDisplayedMonth,
);
expect(displayedDateSetSpy).toHaveBeenCalledWith(expectedDate);
});
it('should correctly identify the selected month', () => {
const comp = spectator.component;
const testDateSameMonth = new Date(
getYear(initialDisplayedDate),
getMonth(initialDisplayedDate),
5,
);
const testDateDifferentMonth = new Date(
getYear(initialDisplayedDate),
getMonth(initialDisplayedDate) + 1,
5,
);
expect(comp.isSelectedMonth(testDateSameMonth)).toBe(true);
expect(comp.isSelectedMonth(testDateDifferentMonth)).toBe(false);
});
it('should correctly identify the selected year', () => {
const comp = spectator.component;
const testDateSameYear = new Date(getYear(initialDisplayedDate), 0, 1);
const testDateDifferentYear = new Date(
getYear(initialDisplayedDate) + 1,
0,
1,
);
expect(comp.isSelectedYear(testDateSameYear)).toBe(true);
expect(comp.isSelectedYear(testDateDifferentYear)).toBe(false);
});
});

View File

@@ -0,0 +1,166 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { format, getMonth, getYear, startOfMonth } from 'date-fns';
import { RangeDatepicker } from '../../range-datepicker';
import { ButtonComponent } from '@isa/ui/buttons';
import { isaActionCheck } from '@isa/icons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { DatepickerViewState } from '../../datepicker-view.state';
@Component({
selector: 'ui-month-year-body',
templateUrl: 'month-year-body.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': "['ui-month-year-body']",
},
imports: [ButtonComponent, NgIconComponent],
providers: [
provideIcons({
isaActionCheck,
}),
],
})
export class MonthYearBodyComponent {
/**
* The injected Datepicker instance.
*/
datepicker = inject(RangeDatepicker);
/**
* The state of the Datepicker view.
*/
viewState = inject(DatepickerViewState);
/**
* The current displayed month derived from the DatepickerViewState.
*/
currentDisplayedMonth = getMonth(this.viewState.displayedDate());
/**
* The current displayed year derived from the DatepickerViewState.
*/
currentDisplayedYear = getYear(this.viewState.displayedDate());
/**
* A computed property that returns an array of month objects for the current year.
*
* Each month object contains a formatted label and the corresponding Date value.
* Months outside the min and max bounds are represented as null.
*/
months = computed(() => {
const year = this.currentDisplayedYear;
const min = this.datepicker.min();
const max = this.datepicker.max();
return Array.from({ length: 12 }, (_, i) => {
const date = new Date(year, i, 1);
const beforeMin = min && date < startOfMonth(min);
const afterMax = max && date > startOfMonth(max);
if (beforeMin || afterMax) return null;
return {
label: format(date, 'LLLL'),
value: date,
};
});
});
/**
* A computed property that returns an array of year objects within the allowed range.
*
* Each year object contains a string label and the corresponding Date value.
*/
years = computed(() => {
const min = this.datepicker.min();
const max = this.datepicker.max();
const currentYear = getYear(new Date());
const minYear = min ? getYear(min) : currentYear - 5; // Default: up to 5 years back
const maxYear = max ? getYear(max) : currentYear;
return Array.from({ length: maxYear - minYear + 1 }, (_, i) => {
const year = maxYear - i;
const date = new Date(year, 0, 1);
return {
label: year.toString(),
value: date,
};
});
});
/**
* A computed property that indicates if the currently displayed month and year
* match the displayed date in the DatepickerViewState.
*/
canSave = computed(() => {
const currentMonth = this.currentDisplayedMonth;
const currentYear = this.currentDisplayedYear;
const displayedDate = this.viewState.displayedDate();
const displayedMonth = getMonth(displayedDate);
const displayedYear = getYear(displayedDate);
return currentMonth === displayedMonth && currentYear === displayedYear;
});
/**
* Updates the displayed month in the DatepickerViewState.
*
* @param newValue - A Date object representing the new month.
*/
updateMonth(newValue: Date) {
const month = getMonth(newValue);
const year = getYear(this.viewState.displayedDate());
this.viewState.displayedDate.set(new Date(year, month));
}
/**
* Updates the displayed year in the DatepickerViewState.
*
* @param newValue - A Date object representing the new year.
*/
updateYear(newValue: Date) {
const month = getMonth(this.viewState.displayedDate());
const year = getYear(newValue);
this.viewState.displayedDate.set(new Date(year, month));
}
/**
* Rolls back the displayed month and year in the DatepickerViewState to the original values.
*/
rollbackDisplayedMonthYear() {
this.viewState.displayedDate.set(
new Date(this.currentDisplayedYear, this.currentDisplayedMonth),
);
}
/**
* Checks if the provided date's month matches the currently displayed month.
*
* @param {Date} date - The date to check.
* @returns {boolean} True if the month matches, false otherwise.
*/
isSelectedMonth(date: Date): boolean {
return getMonth(this.viewState.displayedDate()) === getMonth(date);
}
/**
* Checks if the provided date's year matches the currently displayed year.
*
* @param {Date} date - The date to check.
* @returns {boolean} True if the year matches, false otherwise.
*/
isSelectedYear(date: Date): boolean {
return getYear(this.viewState.displayedDate()) === getYear(date);
}
}

View File

@@ -0,0 +1,100 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import { DatepickerBase } from './datepicker-base';
import { UI_DATEPICKER_DEFAULT_MIN, UI_DATEPICKER_DEFAULT_MAX } from './tokens';
describe('DatepickerBase', () => {
// Create a dummy concrete implementation for testing purposes.
class TestDatepicker extends DatepickerBase<string> {
parseValue(value: unknown): string | undefined {
if (typeof value === 'string') {
return value;
} else if (value === null || value === undefined) {
return undefined;
}
throw new Error('Invalid value');
}
}
let spectator: SpectatorService<TestDatepicker>;
const createService = createServiceFactory({
service: TestDatepicker,
providers: [
{ provide: UI_DATEPICKER_DEFAULT_MIN, useValue: undefined },
{ provide: UI_DATEPICKER_DEFAULT_MAX, useValue: undefined },
],
});
beforeEach(() => {
spectator = createService();
// Override the model's 'set' method to allow spying.
spectator.service.value = { set: jest.fn() } as any;
spectator.service.onChange = jest.fn();
spectator.service.onTouched = jest.fn();
});
describe('setValue', () => {
it('should call parseValue and set the value', () => {
spectator.service.setValue('test string');
expect(spectator.service.value.set).toHaveBeenCalledWith('test string');
});
it('should call onChange with parsed value', () => {
spectator.service.setValue('test string');
expect(spectator.service.onChange).toHaveBeenCalledWith('test string');
});
it('should call onTouched', () => {
spectator.service.setValue('test string');
expect(spectator.service.onTouched).toHaveBeenCalled();
});
it('should log an error when parseValue throws', () => {
jest.spyOn(spectator.service, 'parseValue').mockImplementation(() => {
throw new Error('parse error');
});
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
spectator.service.setValue({} as any);
expect(consoleSpy).toHaveBeenCalledWith(
'Error parsing value:',
expect.any(Error),
);
consoleSpy.mockRestore();
});
});
describe('writeValue', () => {
it('should call parseValue and set the value', () => {
spectator.service.writeValue('write test');
expect(spectator.service.value.set).toHaveBeenCalledWith('write test');
});
it('should log an error when parseValue throws', () => {
jest.spyOn(spectator.service, 'parseValue').mockImplementation(() => {
throw new Error('write error');
});
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
spectator.service.writeValue({} as any);
expect(consoleSpy).toHaveBeenCalledWith(
'Error parsing value:',
expect.any(Error),
);
consoleSpy.mockRestore();
});
});
describe('registerOnChange', () => {
it('should register the onChange callback', () => {
const onChangeMock = jest.fn();
spectator.service.registerOnChange(onChangeMock);
expect(spectator.service.onChange).toBe(onChangeMock);
});
});
describe('registerOnTouched', () => {
it('should register the onTouched callback', () => {
const onTouchedMock = jest.fn();
spectator.service.registerOnTouched(onTouchedMock);
expect(spectator.service.onTouched).toBe(onTouchedMock);
});
});
});

View File

@@ -0,0 +1,100 @@
import { DateValue } from './types';
import { ControlValueAccessor } from '@angular/forms';
import { Directive, inject, input, model } from '@angular/core';
import { UI_DATEPICKER_DEFAULT_MAX, UI_DATEPICKER_DEFAULT_MIN } from './tokens';
/**
* Abstract base class for datepicker components.
* Implements the ControlValueAccessor interface for Angular forms.
* Provides common functionality for parsing and setting datepicker values.
*
* @template TValue - The type of the datepicker value.
*/
@Directive()
export abstract class DatepickerBase<TValue> implements ControlValueAccessor {
/**
* Callback function invoked when the control's value changes.
*/
onChange?: (value: TValue | undefined) => void;
/**
* Callback function invoked when the control is touched.
*/
onTouched?: () => void;
/**
* Internal model representing the current datepicker value.
*/
value = model<TValue | undefined>();
/**
* The minimum allowed date for the datepicker.
* Injected using the UI_DATEPICKER_DEFAULT_MIN token.
*/
min = input<DateValue | undefined>(inject(UI_DATEPICKER_DEFAULT_MIN));
/**
* The maximum allowed date for the datepicker.
* Injected using the UI_DATEPICKER_DEFAULT_MAX token.
*/
max = input<DateValue | undefined>(inject(UI_DATEPICKER_DEFAULT_MAX));
/**
* Parses an unknown value into the datepicker's value type.
* Must be implemented by concrete subclasses.
*
* @param value - The value to be parsed.
* @returns The parsed value of type TValue, or undefined if parsing fails.
*/
abstract parseValue(value: unknown): TValue | undefined;
/**
* Sets the datepicker value after parsing the input.
* If parsing fails, logs an error to the console.
*
* @param value - The new value to set.
*/
setValue(value?: TValue) {
try {
const parsedValue = this.parseValue(value);
this.value.set(parsedValue);
this.onChange?.(parsedValue);
this.onTouched?.();
} catch (error) {
console.error('Error parsing value:', error);
}
}
/**
* Writes a new value to the datepicker, as required by the ControlValueAccessor interface.
* If parsing fails, logs an error to the console.
*
* @param obj - The new value to write.
*/
writeValue(obj: unknown) {
try {
const parsedValue = this.parseValue(obj);
this.value.set(parsedValue);
} catch (error) {
console.error('Error parsing value:', error);
}
}
/**
* Registers a callback function that is called when the value changes.
*
* @param fn - The callback function to register.
*/
registerOnChange(fn: typeof this.onChange): void {
this.onChange = fn;
}
/**
* Registers a callback function that is called when the control is touched.
*
* @param fn - The callback function to register.
*/
registerOnTouched(fn: typeof this.onTouched): void {
this.onTouched = fn;
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable, signal } from '@angular/core';
import { DatepickerView } from './types/datepicker-view.type';
/**
* Service for managing the view state of the datepicker.
* Provides reactive signals for the displayed view mode and date.
*/
@Injectable()
export class DatepickerViewState {
/**
* Signal representing the currently displayed view mode of the datepicker.
* Defaults to the day view.
*/
readonly displayedView = signal<DatepickerView>(DatepickerView.Day);
/**
* Signal representing the currently displayed date in the datepicker.
* Initialized with the current date.
*/
readonly displayedDate = signal<Date>(new Date());
}

View File

@@ -0,0 +1,22 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { SelectedDateComponent } from './selected-date.component';
describe('SelectedDateComponent', () => {
let spectator: Spectator<SelectedDateComponent>;
const createComponent = createComponentFactory({
component: SelectedDateComponent,
});
beforeEach(() => {
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should have the host element with the "ui-selected-date" CSS class', () => {
const hostElement = spectator.element;
expect(hostElement.classList).toContain('ui-selected-date');
});
});

View File

@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'ui-selected-date',
templateUrl: 'selected-date.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': "['ui-selected-date']",
},
})
export class SelectedDateComponent {
// TODO: Implement Logic for single select Datepicker
}

View File

@@ -0,0 +1,27 @@
<button
class="ui-selected-month-year__month-cta"
[class.selected]="viewState.displayedView() === 'month'"
type="button"
(click)="viewState.displayedView.set('month')"
>
<span>{{ monthName() }}</span>
@if (viewState.displayedView() === 'month') {
<ng-icon size="0.75rem" name="isaActionChevronUp"></ng-icon>
} @else {
<ng-icon size="0.75rem" name="isaActionChevronDown"></ng-icon>
}
</button>
<button
class="ui-selected-month-year__year-cta"
[class.selected]="viewState.displayedView() === 'year'"
type="button"
(click)="viewState.displayedView.set('year')"
>
<span>{{ yearNumber() }}</span>
@if (viewState.displayedView() === 'year') {
<ng-icon size="0.75rem" name="isaActionChevronUp"></ng-icon>
} @else {
<ng-icon size="0.75rem" name="isaActionChevronDown"></ng-icon>
}
</button>

View File

@@ -0,0 +1,63 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { SelectedMonthYearComponent } from './selected-month-year.component';
import { DatepickerViewState } from '../../datepicker-view.state';
import { format } from 'date-fns';
// Create a spy function to simulate the displayed date behavior
const displayedDateSpy = jest.fn();
// Include needed property "displayedView" as a function
const fakeViewState = {
displayedDate: displayedDateSpy,
displayedView: displayedDateSpy,
} as unknown as Partial<DatepickerViewState>;
const createComponent = createComponentFactory({
component: SelectedMonthYearComponent,
providers: [
{
provide: DatepickerViewState,
useValue: fakeViewState,
},
],
});
describe('SelectedMonthYearComponent', () => {
let spectator: Spectator<SelectedMonthYearComponent>;
beforeEach(() => {
// Set a default displayed date: January 15, 2024
displayedDateSpy.mockReturnValue(new Date(2024, 0, 15));
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should compute the correct month name', () => {
const expectedMonth = format(new Date(2024, 0, 15), 'LLLL');
expect(spectator.component.monthName()).toBe(expectedMonth);
});
it('should compute the correct year number', () => {
const expectedYear = format(new Date(2024, 0, 15), 'yyyy');
expect(spectator.component.yearNumber()).toBe(expectedYear);
});
it('should update computed properties when the displayed date changes', () => {
const newDate = new Date(2025, 5, 10);
// Update fake view state's displayedDate function to return newDate.
displayedDateSpy.mockReturnValue(newDate);
// Re-create the component so that computed properties are initialized with the new value.
spectator = createComponent();
spectator.detectChanges();
const expectedMonth = format(newDate, 'LLLL');
const expectedYear = format(newDate, 'yyyy');
expect(spectator.component.monthName()).toBe(expectedMonth);
expect(spectator.component.yearNumber()).toBe(expectedYear);
});
});

View File

@@ -0,0 +1,53 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { format } from 'date-fns';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
import { DatepickerViewState } from '../../datepicker-view.state';
/**
* A component for displaying the selected month and year.
* It utilizes a DatepickerViewState to display formatted month and year.
*/
@Component({
selector: 'ui-selected-month-year',
templateUrl: 'selected-month-year.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': "['ui-selected-month-year']",
},
imports: [NgIconComponent],
providers: [
provideIcons({
isaActionChevronUp,
isaActionChevronDown,
}),
],
})
export class SelectedMonthYearComponent {
/**
* The state of the datepicker view, injected from DatepickerViewState.
*/
viewState = inject(DatepickerViewState);
/**
* A computed property that returns the formatted month name.
* It formats the displayed date using the 'LLLL' pattern.
*/
readonly monthName = computed(() =>
format(this.viewState.displayedDate(), 'LLLL'),
);
/**
* A computed property that returns the formatted year number.
* It formats the displayed date using the 'yyyy' pattern.
*/
readonly yearNumber = computed(() =>
format(this.viewState.displayedDate(), 'yyyy'),
);
}

View File

@@ -0,0 +1,120 @@
import {
createDirectiveFactory,
SpectatorDirective,
} from '@ngneat/spectator/jest';
import { DateInputDirective } from './date-input.directive';
import { FormsModule } from '@angular/forms';
describe('DateInputDirective', () => {
let spectator: SpectatorDirective<DateInputDirective>;
const createDirective = createDirectiveFactory({
directive: DateInputDirective,
imports: [FormsModule],
});
it('should render formatted date on writeValue', () => {
// Arrange
spectator = createDirective(`<input uiDateInput />`);
const testDate = new Date(2020, 0, 1); // January 1, 2020
// Act
spectator.directive.writeValue(testDate);
spectator.detectChanges();
// Assert
expect((spectator.element as HTMLInputElement).value).toBe('01.01.2020');
});
it('should render an empty string when value is undefined', () => {
// Arrange
spectator = createDirective(`<input uiDateInput />`);
// Act
spectator.directive.writeValue(undefined);
spectator.detectChanges();
// Assert
expect((spectator.element as HTMLInputElement).value).toBe('');
});
it('should parse valid input and update value', () => {
// Arrange
spectator = createDirective(`<input uiDateInput />`);
const inputEl = spectator.element as HTMLInputElement;
inputEl.value = '15.08.2021';
// Act
inputEl.dispatchEvent(new Event('input'));
const parsedDate = spectator.directive.value;
// Assert
expect(parsedDate).toBeDefined();
expect(parsedDate?.getFullYear()).toBe(2021);
expect(parsedDate?.getMonth()).toBe(7); // Month is zero-indexed: August => 7
expect(parsedDate?.getDate()).toBe(15);
});
it('should not update value on invalid input', () => {
// Arrange
spectator = createDirective(`<input uiDateInput />`);
const inputEl = spectator.element as HTMLInputElement;
inputEl.value = 'invalid input';
// Act
inputEl.dispatchEvent(new Event('input'));
// Assert
expect(spectator.directive.value).toBeUndefined();
});
it('should invoke onChange and onTouched when setValue is called', () => {
// Arrange
spectator = createDirective(`<input uiDateInput />`);
const mockOnChange = jest.fn();
const mockOnTouched = jest.fn();
spectator.directive.registerOnChange(mockOnChange);
spectator.directive.registerOnTouched(mockOnTouched);
const newDate = new Date(2022, 5, 10); // June 10, 2022
// Act
spectator.directive.setValue(newDate);
spectator.detectChanges();
// Assert
expect(mockOnChange).toHaveBeenCalledWith(newDate);
expect(mockOnTouched).toHaveBeenCalled();
expect((spectator.element as HTMLInputElement).value).toBe('10.06.2022');
});
it('should update the input view on focusout', () => {
// Arrange
spectator = createDirective(`<input uiDateInput />`);
const testDate = new Date(2021, 11, 25); // December 25, 2021
spectator.directive.writeValue(testDate);
spectator.detectChanges();
// Act
spectator.element.dispatchEvent(new Event('focusout'));
spectator.detectChanges();
// Assert
expect((spectator.element as HTMLInputElement).value).toBe('25.12.2021');
});
it('should update the input view when enter key is pressed', () => {
// Arrange
spectator = createDirective(`<input uiDateInput />`);
const testDate = new Date(2021, 10, 5); // November 5, 2021
spectator.directive.writeValue(testDate);
spectator.detectChanges();
// Act: simulate keydown.enter event
spectator.element.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter' }),
);
spectator.detectChanges();
// Assert
expect((spectator.element as HTMLInputElement).value).toBe('05.11.2021');
});
});

View File

@@ -0,0 +1,136 @@
import {
ChangeDetectorRef,
Directive,
ElementRef,
HostListener,
inject,
Input,
Renderer2,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { format, isValid, parse, isEqual } from 'date-fns';
@Directive({
selector: 'input[uiDateInput]',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: DateInputDirective,
multi: true,
},
],
standalone: true,
})
// TODO: Date Input Directive soll ausgelagert werden in eine eigene Lib
export class DateInputDirective implements ControlValueAccessor {
elementRef = inject(ElementRef);
renderer = inject(Renderer2);
cdr = inject(ChangeDetectorRef);
@Input() value: Date | undefined = undefined;
onChanges?: (value: Date | undefined) => void;
onTouched?: () => void;
/**
* Writes a value to the view.
* @param obj - The new value for the input.
*/
writeValue(obj: Date | undefined): void {
this.setValue(obj, { emit: false });
}
/**
* Registers a callback function to be invoked when the control's value changes.
* @param fn - The callback function.
*/
registerOnChange(fn: (value: Date | undefined) => void): void {
this.onChanges = fn;
}
/**
* Registers a callback function to be invoked when the control is touched.
* @param fn - The callback function.
*/
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
/**
* Sets the value of the input and triggers change detection.
* @param value - The new date value.
* @param options - Optional settings to control event emission and rendering.
*/
setValue(
value: Date | undefined,
options?: { emit?: boolean; render?: boolean },
) {
if (this.value && value && isEqual(this.value, value)) {
return;
}
this.value = value;
this.onTouched?.();
if (options?.emit ?? true) {
this.onChanges?.(value);
}
if (options?.render ?? true) {
this.renderValue();
}
}
/**
* Handles the input event, parses the entered string value into a Date,
* and updates the control's value if valid.
* @param event - The input event containing the new value.
*/
@HostListener('input', ['$event'])
onInput(event: InputEvent) {
const value = (event.target as HTMLInputElement).value;
const date = this.parseStringToDate(value);
if (date) {
this.setValue(date, { render: false });
}
}
/**
* Parses a string into a Date object using the format 'dd.MM.yyyy'.
* @param value - The string representation of the date.
* @returns The parsed Date if valid, otherwise undefined.
*/
private parseStringToDate(value: string | undefined): Date | undefined {
if (!value || typeof value !== 'string') return undefined;
const parsed = parse(value, 'dd.MM.yyyy', new Date());
return isValid(parsed) ? parsed : undefined;
}
/**
* Renders the current value to the input element.
* If the value is set and is a valid Date, formats it as 'dd.MM.yyyy'.
* Otherwise, clears the input display.
*/
@HostListener('focusout')
@HostListener('blur')
@HostListener('keydown.enter')
renderValue() {
if (!this.value) {
this.renderer.setProperty(this.elementRef.nativeElement, 'value', '');
return;
}
if (this.value instanceof Date) {
this.renderer.setProperty(
this.elementRef.nativeElement,
'value',
format(this.value, 'dd.MM.yyyy'),
);
}
this.cdr.markForCheck();
}
}

View File

@@ -0,0 +1,27 @@
<form [formGroup]="form" class="w-full flex flex-row items-end justify-start gap-5 ml-[1.69rem]">
<div class="ui-selected-range__input-wrapper">
<label for="start">VON</label>
<input
id="start"
type="text"
autocomplete="off"
formControlName="start"
uiDateInput
placeholder="TT.MM.JJJJ"
/>
<div class="ui-selected-range__start-focus-indicator"></div>
</div>
<div class="ui-selected-range__input-wrapper">
<label for="stop">BIS</label>
<input
id="stop"
type="text"
autocomplete="off"
formControlName="stop"
uiDateInput
placeholder="TT.MM.JJJJ"
/>
<div class="ui-selected-range__stop-focus-indicator"></div>
</div>
</form>

View File

@@ -0,0 +1,103 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { SelectedRangeComponent } from './selected-range.component';
import { RangeDatepicker } from '../../range-datepicker';
import { ReactiveFormsModule } from '@angular/forms';
import { DateInputDirective } from './date-input.directive';
describe('SelectedRangeComponent', () => {
let spectator: Spectator<SelectedRangeComponent>;
let component: SelectedRangeComponent;
let rangeDatepickerMock: jest.Mocked<RangeDatepicker>;
const testDate1 = new Date(2023, 0, 15); // Jan 15, 2023
const testDate2 = new Date(2023, 1, 20); // Feb 20, 2023
// Stub default dates for min and max
const defaultMinDate = new Date(2022, 0, 1);
const defaultMaxDate = new Date(2023, 11, 31);
const createComponent = createComponentFactory({
component: SelectedRangeComponent,
imports: [ReactiveFormsModule, DateInputDirective],
providers: [
{
provide: RangeDatepicker,
useValue: {
value: jest.fn(),
setValue: jest.fn(),
min: jest.fn().mockReturnValue(defaultMinDate),
max: jest.fn().mockReturnValue(defaultMaxDate),
},
},
],
});
beforeEach(() => {
rangeDatepickerMock = {
value: jest.fn().mockReturnValue(undefined),
setValue: jest.fn(),
min: jest.fn().mockReturnValue(defaultMinDate),
max: jest.fn().mockReturnValue(defaultMaxDate),
} as unknown as jest.Mocked<RangeDatepicker>;
spectator = createComponent({
providers: [
{
provide: RangeDatepicker,
useValue: rangeDatepickerMock,
},
],
});
component = spectator.component;
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should initialize form with values from rangeDatepicker', () => {
// Arrange
const startDate = testDate1;
const endDate = testDate2;
rangeDatepickerMock.value.mockReturnValue([startDate, endDate]);
// Act
component.form = component.createForm();
spectator.detectChanges();
// Assert
expect(component.form.get('start')?.value).toBe(startDate);
expect(component.form.get('stop')?.value).toBe(endDate);
});
it('should update datepicker value when form is valid', () => {
// Arrange
jest.spyOn(component.form, 'valid', 'get').mockReturnValue(true);
// Set the form control values directly with Date objects.
component.form.controls.start.setValue(testDate1);
component.form.controls.stop.setValue(testDate2);
spectator.detectChanges();
// Assert
expect(rangeDatepickerMock.setValue).toHaveBeenCalledWith([
testDate1,
testDate2,
]);
});
it('should not update datepicker value when form is invalid', async () => {
// Arrange
component.form.controls.start.setErrors({ required: true });
component.form.controls.stop.setErrors({ required: true });
jest.spyOn(component.form, 'valid', 'get').mockReturnValue(false);
await new Promise((resolve) => setTimeout(resolve, 0));
// Assert: now expecting update with undefined values.
expect(rangeDatepickerMock.setValue).toHaveBeenCalledWith([
undefined,
undefined,
]);
});
});

View File

@@ -0,0 +1,115 @@
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
untracked,
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import { DateInputDirective } from './date-input.directive';
import { RangeDatepicker } from '../../range-datepicker';
import { dateBoundsValidator } from '../../validators/date-bounds.validator';
import { dateRangeStartStopOrderValidator } from '../../validators/date-range-order.validator';
/**
* SelectedRangeComponent
*
* A UI component for selecting a date range.
* It synchronizes form controls with the underlying RangeDatepicker,
* ensuring that selected dates are within permitted bounds and that the start date
* precedes the stop date.
*/
@Component({
selector: 'ui-selected-range',
templateUrl: 'selected-range.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': "['ui-selected-range']",
},
imports: [ReactiveFormsModule, DateInputDirective],
})
export class SelectedRangeComponent {
rangeDatepicker = inject(RangeDatepicker);
/**
* Creates and returns a reactive form with 'start' and 'stop' form controls.
* Each control is set up with validators to ensure the dates are within bounds.
*
* @returns {FormGroup} The form group containing the date controls.
*/
createForm() {
const value = this.rangeDatepicker.value();
return new FormGroup(
{
start: new FormControl(value?.[0], [
dateBoundsValidator(
this.rangeDatepicker.min(),
this.rangeDatepicker.max(),
),
]),
stop: new FormControl(value?.[1], [
dateBoundsValidator(
this.rangeDatepicker.min(),
this.rangeDatepicker.max(),
),
]),
},
{
validators: dateRangeStartStopOrderValidator('start', 'stop'),
},
);
}
/** The reactive form instance managing selected date range values. */
form: ReturnType<typeof this.createForm> = this.createForm();
/** Shortcut to access the 'start' FormControl of the form. */
start = this.form.controls['start'];
/** Shortcut to access the 'stop' FormControl of the form. */
stop = this.form.controls['stop'];
/**
* A signal representing the form's value changes.
* Updates whenever the form's values change.
*/
valueChanges = toSignal(this.form.valueChanges);
/**
* Effect to patch form values when the RangeDatepicker's value changes.
* If no value is provided, the form gets reset.
*/
patchFormValuesEffectFn = effect(() => {
const value = this.rangeDatepicker.value();
if (!value) {
this.form.reset();
return;
}
untracked(() => {
const [start, stop] = value;
this.form.patchValue({ start, stop }, { emitEvent: false });
});
});
/**
* Effect to update the RangeDatepicker's value based on the form's current valid state.
* It triggers on form value changes and only sets the datepicker's value if the form
* and its controls are valid.
*/
setDatepickerValueEffectFn = effect(() => {
this.valueChanges();
untracked(() => {
if (this.form.valid && this.start.valid && this.stop.valid) {
this.rangeDatepicker.setValue([
this.start.value ?? undefined,
this.stop.value ?? undefined,
]);
}
});
});
}

View File

@@ -0,0 +1,13 @@
import { inject } from '@angular/core';
import { RangeDatepicker } from './range-datepicker';
/**
* Injects an instance of RangeDatepicker using Angular's dependency injection.
*
* This function simplifies accessing the RangeDatepicker instance across the application.
*
* @returns {RangeDatepicker} An instance of the RangeDatepicker.
*/
export function injectDatepicker(): RangeDatepicker {
return inject(RangeDatepicker);
}

View File

@@ -0,0 +1,7 @@
@if (viewState.displayedView() === 'day') {
<ui-selected-range></ui-selected-range>
<ui-calendar-body></ui-calendar-body>
} @else {
<ui-selected-month-year></ui-selected-month-year>
<ui-month-year-body></ui-month-year-body>
}

View File

@@ -0,0 +1,83 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { SimpleChanges } from '@angular/core';
import { RangeDatepickerComponent } from './range-datepicker.component';
describe('RangeDatepickerComponent', () => {
let spectator: Spectator<RangeDatepickerComponent>;
const createComponent = createComponentFactory(RangeDatepickerComponent);
beforeEach(() => {
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should set the default displayed date from provided value on first change', () => {
const testDate = new Date(2021, 0, 1);
// Override the inherited value() method to return a specific date array.
spectator.setInput('value', [testDate]);
const spySet = jest.spyOn(
spectator.component.viewState.displayedDate,
'set',
);
const changes: SimpleChanges = {
value: {
previousValue: undefined,
currentValue: [testDate],
firstChange: true,
isFirstChange: () => true,
},
};
spectator.component.ngOnChanges(changes);
expect(spySet).toHaveBeenCalledWith(testDate);
});
it('should default the displayed date to current date when value is undefined on first change', () => {
// Override value() to return undefined.
spectator.setInput('value', undefined);
const spySet = jest.spyOn(
spectator.component.viewState.displayedDate,
'set',
);
const changes: SimpleChanges = {
value: {
previousValue: undefined,
currentValue: undefined,
firstChange: true,
isFirstChange: () => true,
},
};
spectator.component.ngOnChanges(changes);
const result = spectator.component.setDefaultDisplayedMonthYear();
const now = Date.now();
// Check that the resulting date is close to the current date (within one second).
expect(Math.abs(result.getTime() - now)).toBeLessThan(1000);
expect(spySet).toHaveBeenCalled();
const calledWith: Date = spySet.mock.calls[0][0];
expect(Math.abs(calledWith.getTime() - now)).toBeLessThan(1000);
});
it('should not change the displayed date if value is not a first change', () => {
// Even if value() returns a date array, no change should be applied on subsequent changes.
spectator.setInput('value', [new Date(2021, 0, 1)]);
const spySet = jest.spyOn(
spectator.component.viewState.displayedDate,
'set',
);
const changes: SimpleChanges = {
value: {
previousValue: [new Date(2020, 0, 1)],
currentValue: [new Date(2021, 0, 1)],
firstChange: false,
isFirstChange: () => false,
},
};
spectator.component.ngOnChanges(changes);
expect(spySet).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import {
ChangeDetectionStrategy,
Component,
forwardRef,
inject,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { CalendarBodyComponent } from './body/calendar-body/calendar-body.component';
import { SelectedRangeComponent } from './header/selected-range/selected-range.component';
import { SelectedMonthYearComponent } from './header/selected-month-year/selected-month-year.component';
import { MonthYearBodyComponent } from './body/month-year-body/month-year-body.component';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { DatepickerViewState } from './datepicker-view.state';
import { RangeDatepicker } from './range-datepicker';
/**
* A component that extends the RangeDatepicker to provide a fully featured date range
* picker with additional view state management.
*
* This component handles changes to the date picker value and sets the default displayed
* month and year accordingly.
*/
@Component({
selector: 'ui-range-datepicker',
templateUrl: 'range-datepicker.component.html',
providers: [
{
provide: RangeDatepicker,
useExisting: RangeDatepickerComponent,
},
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RangeDatepickerComponent),
multi: true,
},
DatepickerViewState,
],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
SelectedMonthYearComponent,
SelectedRangeComponent,
CalendarBodyComponent,
MonthYearBodyComponent,
],
host: {
'[class]': "['ui-range-datepicker']",
},
})
export class RangeDatepickerComponent
extends RangeDatepicker
implements OnChanges
{
/**
* The view state for the Datepicker, injected via Angular's dependency injection.
*/
viewState = inject(DatepickerViewState);
/**
* Lifecycle hook that is called when any data-bound property of a directive changes.
* If the 'value' property is changed for the first time, it sets the default displayed
* date in the view state.
*
* @param changes - An object of key/value pairs for the set of changed properties.
*/
ngOnChanges(changes: SimpleChanges): void {
if (changes['value']?.firstChange) {
this.viewState.displayedDate.set(this.setDefaultDisplayedMonthYear());
}
}
/**
* Determines the default displayed month and year based on the current value.
* If a date range is set, the start date is used;
* otherwise, the current date is returned.
*
* @returns {Date} The default date to be displayed in the datepicker.
*/
setDefaultDisplayedMonthYear(): Date {
return this.value()?.[0] ?? new Date();
}
}

View File

@@ -0,0 +1,130 @@
import { Component } from '@angular/core';
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { RangeDatepicker } from './range-datepicker';
import { DateRangeValue } from './types';
// Dummy host component without providers.
@Component({
template: '',
})
class DummyHostComponent {}
describe('RangeDatepicker Directive', () => {
let spectator: Spectator<DummyHostComponent>;
let directive: RangeDatepicker;
// Provide RangeDatepicker via the factory configuration.
const createComponent = createComponentFactory({
component: DummyHostComponent,
providers: [RangeDatepicker],
});
beforeEach(() => {
// Create the host component and inject RangeDatepicker.
spectator = createComponent();
directive = spectator.inject(RangeDatepicker);
});
describe('parseValue', () => {
it('should return a valid date range when provided a valid array', () => {
// Arrange
const validRange: DateRangeValue = [
new Date(2021, 0, 1),
new Date(2021, 0, 2),
];
// Act
const result = directive.parseValue(validRange);
// Assert
expect(result).toEqual(validRange);
});
it('should throw an error when provided an invalid value', () => {
// Arrange
const invalidValue = 'invalid';
// Act & Assert
expect(() => directive.parseValue(invalidValue)).toThrow();
});
});
describe('sortRangeValuesAsc', () => {
it('should return the original range when either start or end is missing', () => {
// Arrange
const range1: DateRangeValue = [new Date(2021, 0, 1), undefined];
const range2: DateRangeValue = [undefined, new Date(2021, 0, 2)];
// Act & Assert
expect(directive.sortRangeValuesAsc(range1)).toBe(range1);
expect(directive.sortRangeValuesAsc(range2)).toBe(range2);
});
it('should sort the dates in ascending order when end is before start', () => {
// Arrange
const start = new Date(2021, 0, 2);
const end = new Date(2021, 0, 1);
// Act
const sorted = directive.sortRangeValuesAsc([start, end]);
// Assert
expect(sorted[0]).toEqual(end);
expect(sorted[1]).toEqual(start);
});
it('should return the range unchanged if already in ascending order', () => {
// Arrange
const start = new Date(2021, 0, 1);
const end = new Date(2021, 0, 2);
// Act
const sorted = directive.sortRangeValuesAsc([start, end]);
// Assert
expect(sorted[0]).toEqual(start);
expect(sorted[1]).toEqual(end);
});
});
describe('setDateRange', () => {
let valueMock: jest.Mock, setValueMock: jest.Mock;
beforeEach(() => {
valueMock = jest.fn();
setValueMock = jest.fn();
// Override the read-only model properties using Object.defineProperty.
Object.defineProperty(directive, 'value', { value: valueMock });
Object.defineProperty(directive, 'setValue', { value: setValueMock });
});
it('should set a new range with end undefined when current value is fully set', () => {
// Arrange: current value is fully set.
const currentRange: DateRangeValue = [
new Date(2021, 0, 1),
new Date(2021, 0, 2),
];
valueMock.mockReturnValue(currentRange);
const newDate = new Date(2021, 0, 3);
// Act
directive.setDateRange(newDate);
// Assert
expect(setValueMock).toHaveBeenCalledWith([newDate, undefined]);
});
it('should set the start date when only end is present', () => {
// Arrange: only end is present.
const currentRange: DateRangeValue = [undefined, new Date(2021, 0, 2)];
valueMock.mockReturnValue(currentRange);
const newDate = new Date(2021, 0, 3);
// Act
directive.setDateRange(newDate);
const expected = directive.sortRangeValuesAsc([newDate, currentRange[1]]);
// Assert
expect(setValueMock).toHaveBeenCalledWith(expected);
});
it('should set the end date when only start is present', () => {
// Arrange: only start is present.
const currentRange: DateRangeValue = [new Date(2021, 0, 1), undefined];
valueMock.mockReturnValue(currentRange);
const newDate = new Date(2021, 0, 3);
// Act
directive.setDateRange(newDate);
const expected = directive.sortRangeValuesAsc([currentRange[0], newDate]);
// Assert
expect(setValueMock).toHaveBeenCalledWith(expected);
});
});
});

View File

@@ -0,0 +1,62 @@
import { Directive } from '@angular/core';
import { isBefore } from 'date-fns';
import { DatepickerBase } from './datepicker-base';
import { DateRangeValue, DateRangeSchema } from './types';
/**
* Directive for a date range picker extending the DatepickerBase functionality.
* Allows parsing and setting of date range values with proper ordering.
*/
@Directive()
export class RangeDatepicker extends DatepickerBase<DateRangeValue> {
/**
* Parses an unknown value into a DateRangeValue using the DateRangeSchema.
*
* @param value - The value to be parsed.
* @returns The parsed DateRangeValue or undefined if parsing fails.
*/
parseValue(value: unknown): DateRangeValue | undefined {
return DateRangeSchema.optional().parse(value);
}
/**
* Sets the date range based on the provided date.
* Determines whether to assign the date as the start or end value,
* while ensuring the start date is less than the end date.
*
* @param value - The date to be set in the range.
*/
setDateRange(value: Date) {
const currentValue = this.value();
if (!Array.isArray(currentValue) || currentValue.every((v) => !!v)) {
this.setValue([value, undefined]);
}
// Set start value when only end value is present and ensure start < end
else if (!currentValue[0]) {
this.setValue(this.sortRangeValuesAsc([value, currentValue[1]]));
}
// Set end value when only start value is present and ensure start < end
else {
this.setValue(this.sortRangeValuesAsc([currentValue[0], value]));
}
}
/**
* Sorts the provided date range values in ascending order.
* If either start or end is missing, returns the original range.
* Uses date-fns isBefore function to compare dates.
*
* @param rangeValues - An array with two dates representing the start and end.
* @returns The sorted date range in ascending order.
*/
sortRangeValuesAsc(rangeValues: DateRangeValue): DateRangeValue {
const [start, end] = rangeValues;
if (!start || !end) {
return rangeValues;
}
return isBefore(end, start) ? [end, start] : [start, end];
}
}

View File

@@ -0,0 +1,29 @@
import { InjectionToken } from '@angular/core';
import { subYears, addYears } from 'date-fns';
import { DateValue } from './types';
/**
* Injection token for the default minimum date value in the datepicker.
* Uses date-fns subYears to calculate a default date 4 years in the past.
*
* @type {InjectionToken<DateValue | undefined>}
*/
export const UI_DATEPICKER_DEFAULT_MIN = new InjectionToken<
DateValue | undefined
>('UI_DATEPICKER_DEFAULT_MIN', {
providedIn: 'root',
factory: () => subYears(new Date(), 4), // Default to January 1, 2022
});
/**
* Injection token for the default maximum date value in the datepicker.
* Uses date-fns addYears to calculate a default date 1 year in the future.
*
* @type {InjectionToken<DateValue | undefined>}
*/
export const UI_DATEPICKER_DEFAULT_MAX = new InjectionToken<
DateValue | undefined
>('UI_DATEPICKER_DEFAULT_MAX', {
providedIn: 'root',
factory: () => addYears(new Date(), 1), // No default max date
});

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
/**
* Zod schema for validating a date range.
*
* The schema expects a tuple containing two optional date values:
* - The first element represents the start date.
* - The second element represents the end date.
*/
export const DateRangeSchema = z.tuple([
z.coerce.date().optional(), // start
z.coerce.date().optional(), // end
]);
/**
* Type representing a date range value inferred from DateRangeSchema.
*/
export type DateRangeValue = z.infer<typeof DateRangeSchema>;

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
/**
* A Zod schema that coerces input to a Date object.
* This is used to parse and validate incoming date values.
*/
export const DateSchema = z.coerce.date();
/**
* Type representing a date value inferred from DateSchema.
*/
export type DateValue = z.infer<typeof DateSchema>;

View File

@@ -0,0 +1,15 @@
/**
* An object representing the available views in the datepicker.
*/
export const DatepickerView = {
Day: 'day',
Month: 'month',
Year: 'year',
} as const;
/**
* A type representing the possible views for the datepicker.
* It can be either 'day', 'month', or 'year'.
*/
export type DatepickerView =
(typeof DatepickerView)[keyof typeof DatepickerView];

View File

@@ -0,0 +1,17 @@
/**
* An object representing the days of the week as constant string values.
*/
export const DaysOfWeek = {
Monday: 'monday',
Tuesday: 'tuesday',
Wednesday: 'wednesday',
Thursday: 'thursday',
Friday: 'friday',
Saturday: 'saturday',
Sunday: 'sunday',
} as const;
/**
* A type representing a single day of the week derived from the DaysOfWeek object.
*/
export type DaysOfWeek = (typeof DaysOfWeek)[keyof typeof DaysOfWeek];

View File

@@ -0,0 +1,4 @@
export * from './datepicker-view.type';
export * from './days-of-week.type';
export * from './date-range-value.type';
export * from './date-value.type';

View File

@@ -0,0 +1,69 @@
import { AbstractControl } from '@angular/forms';
import { dateBoundsValidator } from './date-bounds.validator';
describe('dateBoundsValidator', () => {
const createControl = (value: any): AbstractControl =>
({
value,
}) as AbstractControl;
it('should return null if control value is empty', () => {
const control = createControl(null); // statt ''
const validator = dateBoundsValidator(
new Date(2023, 0, 1),
new Date(2023, 11, 31),
);
expect(validator(control)).toBeNull();
});
it('should return { invalidDate: true } if the control value is not a valid date', () => {
const control = createControl(new Date('invalid'));
const validator = dateBoundsValidator(
new Date(2023, 0, 1),
new Date(2023, 11, 31),
);
expect(validator(control)).toEqual({ invalidDate: true });
});
it('should return { minDate: true } if the date is before the minimum bound', () => {
const minDate = new Date(2023, 0, 1); // Jan 1, 2023
const control = createControl(new Date(2022, 11, 31)); // Dec 31, 2022
const validator = dateBoundsValidator(minDate, undefined);
expect(validator(control)).toEqual({ minDate: true });
});
it('should return { maxDate: true } if the date is after the maximum bound', () => {
const maxDate = new Date(2023, 11, 31); // Dec 31, 2023
const control = createControl(new Date(2024, 0, 1)); // Jan 1, 2024
const validator = dateBoundsValidator(undefined, maxDate);
expect(validator(control)).toEqual({ maxDate: true });
});
it('should return null if the date equals the minimum bound', () => {
const minDate = new Date(2023, 0, 1);
const control = createControl(new Date(2023, 0, 1));
const validator = dateBoundsValidator(minDate, undefined);
expect(validator(control)).toBeNull();
});
it('should return null if the date equals the maximum bound', () => {
const maxDate = new Date(2023, 11, 31);
const control = createControl(new Date(2023, 11, 31));
const validator = dateBoundsValidator(undefined, maxDate);
expect(validator(control)).toBeNull();
});
it('should return null if the date is within the provided bounds', () => {
const minDate = new Date(2023, 0, 1);
const maxDate = new Date(2023, 11, 31);
const control = createControl(new Date(2023, 5, 15)); // June 15, 2023
const validator = dateBoundsValidator(minDate, maxDate);
expect(validator(control)).toBeNull();
});
it('should return null if no bounds are provided', () => {
const control = createControl(new Date(2023, 5, 15)); // June 15, 2023
const validator = dateBoundsValidator();
expect(validator(control)).toBeNull();
});
});

View File

@@ -0,0 +1,30 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { isValid, isBefore, isAfter } from 'date-fns';
/**
* Validator function to check if a date value is within the specified bounds.
*
* This validator performs the following checks:
* - If the control value is empty, the validation passes.
* - If the date is invalid, a validation error with {invalidDate: true} is returned.
* - If a minimum date is provided and the control date is before it, a validation error with {minDate: true} is returned.
* - If a maximum date is provided and the control date is after it, a validation error with {maxDate: true} is returned.
*
* @param min - The minimum allowed date (inclusive). Optional.
* @param max - The maximum allowed date (inclusive). Optional.
* @returns A ValidatorFn that returns a ValidationErrors object if the date is out of bounds, otherwise null.
*/
// TODO: Utils Auslagern
export function dateBoundsValidator(min?: Date, max?: Date): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const date: Date | null = control.value ?? null;
if (!date) return null;
if (!isValid(date)) return { invalidDate: true };
if (min && isBefore(date, min)) return { minDate: true };
if (max && isAfter(date, max)) return { maxDate: true };
return null;
};
}

View File

@@ -0,0 +1,65 @@
import { AbstractControl } from '@angular/forms';
import { dateRangeStartStopOrderValidator } from './date-range-order.validator';
describe('dateRangeStartStopOrderValidator', () => {
// Helper function to create a mock AbstractControl with Date values
const createGroup = (startValue: any, stopValue: any): AbstractControl => {
return {
get: (key: string) => {
if (key === 'start') {
return { value: startValue } as AbstractControl;
}
if (key === 'stop') {
return { value: stopValue } as AbstractControl;
}
return null;
},
} as AbstractControl;
};
it('should return null if either start or stop value is missing', () => {
const validator = dateRangeStartStopOrderValidator('start', 'stop');
// Missing start value
let group = createGroup(null, new Date(2022, 0, 1)); // 01.01.2022
expect(validator(group)).toBeNull();
// Missing stop value
group = createGroup(new Date(2022, 0, 1), null);
expect(validator(group)).toBeNull();
});
it('should return null if either start or stop is an invalid date', () => {
const validator = dateRangeStartStopOrderValidator('start', 'stop');
// Invalid start date
const invalidDate = new Date('invalid');
let group = createGroup(invalidDate, new Date(2022, 0, 1));
expect(validator(group)).toBeNull();
// Invalid stop date
group = createGroup(new Date(2022, 0, 1), invalidDate);
expect(validator(group)).toBeNull();
});
it('should return error { startAfterStop: true } when start date is after stop date', () => {
const validator = dateRangeStartStopOrderValidator('start', 'stop');
const group = createGroup(new Date(2022, 0, 2), new Date(2022, 0, 1));
expect(validator(group)).toEqual({ startAfterStop: true });
});
it('should return null when start date equals stop date', () => {
const validator = dateRangeStartStopOrderValidator('start', 'stop');
const group = createGroup(new Date(2022, 0, 1), new Date(2022, 0, 1));
expect(validator(group)).toBeNull();
});
it('should return null when start date is before stop date', () => {
const validator = dateRangeStartStopOrderValidator('start', 'stop');
const group = createGroup(new Date(2022, 0, 1), new Date(2022, 0, 2));
expect(validator(group)).toBeNull();
});
});

View File

@@ -0,0 +1,37 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { isAfter, isValid } from 'date-fns';
/**
* Validator function to check that the start date is not after the stop date.
*
* This validator retrieves the values for the start and stop dates from the form group using
* the provided keys. It performs the following checks:
* - If either date is missing, the validation passes.
* - If either date is invalid, the validation passes.
* - If the start date is after the stop date, a validation error is returned.
*
* @param startKey - The key identifying the control containing the start date.
* @param stopKey - The key identifying the control containing the stop date.
* @returns A ValidatorFn that returns a ValidationErrors object with {startAfterStop: true}
* if the start date is after the stop date, otherwise null.
*/
// TODO: Utils auslagern
export function dateRangeStartStopOrderValidator(
startKey: string,
stopKey: string,
): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const start: Date | null = group.get(startKey)?.value ?? null;
const stop: Date | null = group.get(stopKey)?.value ?? null;
if (!start || !stop) return null;
if (!isValid(start) || !isValid(stop)) return null;
if (isAfter(start, stop)) {
return { startAfterStop: true };
}
return null;
};
}

View File

@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -1,4 +1,4 @@
@use "./lib/checkbox/checkbox";
@use "./lib/chips/chips";
@use "./lib/dropdown/dropdown";
@use "./lib/text-field/text-field.scss";
@use './lib/checkbox/checkbox';
@use './lib/chips/chips';
@use './lib/dropdown/dropdown';
@use './lib/text-field/text-field.scss';

View File

@@ -10,32 +10,32 @@
display: none;
}
}
}
input[type='text'] {
appearance: none;
flex-grow: 1;
input[type='text'] {
appearance: none;
flex-grow: 1;
font-size: 0.875rem;
font-style: normal;
font-weight: 700;
line-height: 1.25rem; /* 142.857% */
@apply text-isa-neutral-900;
&::placeholder {
@apply text-isa-neutral-500;
font-size: 0.875rem;
font-style: normal;
font-weight: 700;
line-height: 1.25rem; /* 142.857% */
@apply text-isa-neutral-900;
&:hover,
&:focus {
@apply text-isa-neutral-900;
&::placeholder {
@apply text-isa-neutral-500;
font-size: 0.875rem;
font-style: normal;
font-weight: 700;
line-height: 1.25rem; /* 142.857% */
&:hover,
&:focus {
@apply text-isa-neutral-900;
}
}
}
@apply focus:outline-none;
@apply focus:outline-none;
}
}
.ui-search-bar__actions {

11
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"@ngrx/store-devtools": "19.1.0",
"angular-oauth2-oidc": "^17.0.2",
"angular-oauth2-oidc-jwks": "^17.0.2",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"ng2-pdf-viewer": "^10.4.0",
@@ -16976,6 +16977,16 @@
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-format": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",

View File

@@ -42,6 +42,7 @@
"@ngrx/store-devtools": "19.1.0",
"angular-oauth2-oidc": "^17.0.2",
"angular-oauth2-oidc-jwks": "^17.0.2",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"ng2-pdf-viewer": "^10.4.0",

View File

@@ -61,6 +61,7 @@
"@isa/shared/filter": ["libs/shared/filter/src/index.ts"],
"@isa/shared/product-image": ["libs/shared/product-image/src/index.ts"],
"@isa/ui/buttons": ["libs/ui/buttons/src/index.ts"],
"@isa/ui/datepicker": ["libs/ui/datepicker/src/index.ts"],
"@isa/ui/empty-state": ["libs/ui/empty-state/src/index.ts"],
"@isa/ui/input-controls": ["libs/ui/input-controls/src/index.ts"],
"@isa/ui/item-rows": ["libs/ui/item-rows/src/index.ts"],