mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merge branch 'develop' into feature/5032-Filter-Menu-Refinement
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
33
apps/isa-app/stories/ui/datepicker/ui-datepicker.stories.ts
Normal file
33
apps/isa-app/stories/ui/datepicker/ui-datepicker.stories.ts
Normal 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
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<h1>📅📅📅</h1>
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './datepicker-input.component';
|
||||
@@ -0,0 +1,8 @@
|
||||
@let inp = input();
|
||||
@if (inp) {
|
||||
<ui-range-datepicker
|
||||
[formControl]="datepicker"
|
||||
[min]="datepickerMin()"
|
||||
[max]="datepickerMax()"
|
||||
></ui-range-datepicker>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.filter-datepicker-range-input {
|
||||
@apply inline-block;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './datepicker-range-input.component';
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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']",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
7
libs/ui/datepicker/README.md
Normal file
7
libs/ui/datepicker/README.md
Normal 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.
|
||||
34
libs/ui/datepicker/eslint.config.mjs
Normal file
34
libs/ui/datepicker/eslint.config.mjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
21
libs/ui/datepicker/jest.config.ts
Normal file
21
libs/ui/datepicker/jest.config.ts
Normal 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',
|
||||
],
|
||||
};
|
||||
20
libs/ui/datepicker/project.json
Normal file
20
libs/ui/datepicker/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
libs/ui/datepicker/src/datepicker.scss
Normal file
6
libs/ui/datepicker/src/datepicker.scss
Normal 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';
|
||||
5
libs/ui/datepicker/src/index.ts
Normal file
5
libs/ui/datepicker/src/index.ts
Normal 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';
|
||||
98
libs/ui/datepicker/src/lib/_calendar-body.scss
Normal file
98
libs/ui/datepicker/src/lib/_calendar-body.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
23
libs/ui/datepicker/src/lib/_month-year-body.scss
Normal file
23
libs/ui/datepicker/src/lib/_month-year-body.scss
Normal 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];
|
||||
}
|
||||
}
|
||||
3
libs/ui/datepicker/src/lib/_range-datepicker.scss
Normal file
3
libs/ui/datepicker/src/lib/_range-datepicker.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.ui-range-datepicker {
|
||||
@apply inline-block w-[18.375rem] font-sans;
|
||||
}
|
||||
0
libs/ui/datepicker/src/lib/_selected-date.scss
Normal file
0
libs/ui/datepicker/src/lib/_selected-date.scss
Normal file
19
libs/ui/datepicker/src/lib/_selected-month-year.scss
Normal file
19
libs/ui/datepicker/src/lib/_selected-month-year.scss
Normal 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];
|
||||
}
|
||||
}
|
||||
36
libs/ui/datepicker/src/lib/_selected-range.scss
Normal file
36
libs/ui/datepicker/src/lib/_selected-range.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
100
libs/ui/datepicker/src/lib/datepicker-base.spec.ts
Normal file
100
libs/ui/datepicker/src/lib/datepicker-base.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
100
libs/ui/datepicker/src/lib/datepicker-base.ts
Normal file
100
libs/ui/datepicker/src/lib/datepicker-base.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
21
libs/ui/datepicker/src/lib/datepicker-view.state.ts
Normal file
21
libs/ui/datepicker/src/lib/datepicker-view.state.ts
Normal 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());
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
13
libs/ui/datepicker/src/lib/inject-datepicker.ts
Normal file
13
libs/ui/datepicker/src/lib/inject-datepicker.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
83
libs/ui/datepicker/src/lib/range-datepicker.component.ts
Normal file
83
libs/ui/datepicker/src/lib/range-datepicker.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
130
libs/ui/datepicker/src/lib/range-datepicker.spec.ts
Normal file
130
libs/ui/datepicker/src/lib/range-datepicker.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
libs/ui/datepicker/src/lib/range-datepicker.ts
Normal file
62
libs/ui/datepicker/src/lib/range-datepicker.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
29
libs/ui/datepicker/src/lib/tokens.ts
Normal file
29
libs/ui/datepicker/src/lib/tokens.ts
Normal 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
|
||||
});
|
||||
18
libs/ui/datepicker/src/lib/types/date-range-value.type.ts
Normal file
18
libs/ui/datepicker/src/lib/types/date-range-value.type.ts
Normal 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>;
|
||||
12
libs/ui/datepicker/src/lib/types/date-value.type.ts
Normal file
12
libs/ui/datepicker/src/lib/types/date-value.type.ts
Normal 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>;
|
||||
15
libs/ui/datepicker/src/lib/types/datepicker-view.type.ts
Normal file
15
libs/ui/datepicker/src/lib/types/datepicker-view.type.ts
Normal 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];
|
||||
17
libs/ui/datepicker/src/lib/types/days-of-week.type.ts
Normal file
17
libs/ui/datepicker/src/lib/types/days-of-week.type.ts
Normal 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];
|
||||
4
libs/ui/datepicker/src/lib/types/index.ts
Normal file
4
libs/ui/datepicker/src/lib/types/index.ts
Normal 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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
6
libs/ui/datepicker/src/test-setup.ts
Normal file
6
libs/ui/datepicker/src/test-setup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
28
libs/ui/datepicker/tsconfig.json
Normal file
28
libs/ui/datepicker/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
17
libs/ui/datepicker/tsconfig.lib.json
Normal file
17
libs/ui/datepicker/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
16
libs/ui/datepicker/tsconfig.spec.json
Normal file
16
libs/ui/datepicker/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user