mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
committed by
Lorenz Hilpert
parent
1c2d0421c4
commit
3d18e45f59
@@ -14,7 +14,10 @@ export class CanActivateGoodsInGuard {
|
|||||||
|
|
||||||
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
||||||
const pid = this._config.get('process.ids.goodsIn', z.number());
|
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) {
|
if (!process) {
|
||||||
await this._applicationService.createProcess({
|
await this._applicationService.createProcess({
|
||||||
id: this._config.get('process.ids.goodsIn'),
|
id: this._config.get('process.ids.goodsIn'),
|
||||||
@@ -23,7 +26,9 @@ export class CanActivateGoodsInGuard {
|
|||||||
name: '',
|
name: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._applicationService.activateProcess(this._config.get('process.ids.goodsIn'));
|
this._applicationService.activateProcess(
|
||||||
|
this._config.get('process.ids.goodsIn'),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { enableProdMode, isDevMode } from '@angular/core';
|
import { enableProdMode, isDevMode } from '@angular/core';
|
||||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||||
import { CONFIG_DATA } from '@isa/core/config';
|
import { CONFIG_DATA } from '@isa/core/config';
|
||||||
|
import { setDefaultOptions } from 'date-fns';
|
||||||
|
import { de } from 'date-fns/locale';
|
||||||
import * as moment from 'moment';
|
import * as moment from 'moment';
|
||||||
|
|
||||||
|
setDefaultOptions({ locale: de });
|
||||||
moment.locale('de');
|
moment.locale('de');
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
@@ -16,7 +19,9 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const config = await configRes.json();
|
const config = await configRes.json();
|
||||||
|
|
||||||
platformBrowserDynamic([{ provide: CONFIG_DATA, useValue: config }]).bootstrapModule(AppModule);
|
platformBrowserDynamic([
|
||||||
|
{ provide: CONFIG_DATA, useValue: config },
|
||||||
|
]).bootstrapModule(AppModule);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -24,4 +29,3 @@ try {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
@use "../../../libs/ui/buttons/src/buttons.scss";
|
@use '../../../libs/ui/buttons/src/buttons.scss';
|
||||||
@use "../../../libs/ui/input-controls/src/input-controls.scss";
|
@use '../../../libs/ui/datepicker/src/datepicker.scss';
|
||||||
@use "../../../libs/ui/progress-bar/src/lib/progress-bar.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
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="flex flex-row gap-4">
|
<div class="flex flex-row gap-2">
|
||||||
@for (filterInput of filterInputs(); track filterInput.key) {
|
@for (filterInput of filterInputs(); track filterInput.key) {
|
||||||
<filter-input-menu-button [filterInput]="filterInput" (applied)="onSearch()">
|
<filter-input-menu-button [filterInput]="filterInput" (applied)="onSearch()">
|
||||||
</filter-input-menu-button>
|
</filter-input-menu-button>
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ function mapToTextFilterInput(group: string, input: Input): TextFilterInput {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapToCheckboxFilterInput(group: string, input: Input): CheckboxFilterInput {
|
function mapToCheckboxFilterInput(
|
||||||
|
group: string,
|
||||||
|
input: Input,
|
||||||
|
): CheckboxFilterInput {
|
||||||
return CheckboxFilterInputSchema.parse({
|
return CheckboxFilterInputSchema.parse({
|
||||||
group,
|
group,
|
||||||
key: input.key,
|
key: input.key,
|
||||||
@@ -93,8 +96,9 @@ function mapToCheckboxFilterInput(group: string, input: Input): CheckboxFilterIn
|
|||||||
maxOptions: input.options?.max,
|
maxOptions: input.options?.max,
|
||||||
options: input.options?.values?.map(mapToCheckboxOption),
|
options: input.options?.values?.map(mapToCheckboxOption),
|
||||||
selected:
|
selected:
|
||||||
input.options?.values?.filter((option) => option.selected).map((option) => option.value) ||
|
input.options?.values
|
||||||
[],
|
?.filter((option) => option.selected)
|
||||||
|
.map((option) => option.value) || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +109,10 @@ function mapToCheckboxOption(option: Option): CheckboxFilterInputOption {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapToDateRangeFilterInput(group: string, input: Input): DateRangeFilterInput {
|
function mapToDateRangeFilterInput(
|
||||||
|
group: string,
|
||||||
|
input: Input,
|
||||||
|
): DateRangeFilterInput {
|
||||||
return DateRangeFilterInputSchema.parse({
|
return DateRangeFilterInputSchema.parse({
|
||||||
group,
|
group,
|
||||||
key: input.key,
|
key: input.key,
|
||||||
@@ -113,6 +120,10 @@ function mapToDateRangeFilterInput(group: string, input: Input): DateRangeFilter
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
type: InputType.DateRange,
|
type: InputType.DateRange,
|
||||||
start: input.options?.values?.[0].value,
|
start: input.options?.values?.[0].value,
|
||||||
|
minStart: input.options?.values?.[0].minValue,
|
||||||
|
maxStart: input.options?.values?.[0].maxValue,
|
||||||
stop: input.options?.values?.[1].value,
|
stop: input.options?.values?.[1].value,
|
||||||
|
minStop: input.options?.values?.[1].minValue,
|
||||||
|
maxStop: input.options?.values?.[1].maxValue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ export const CheckboxFilterInputSchema = BaseFilterInputSchema.extend({
|
|||||||
export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({
|
export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({
|
||||||
type: z.literal(InputType.DateRange),
|
type: z.literal(InputType.DateRange),
|
||||||
start: z.string().optional(),
|
start: z.string().optional(),
|
||||||
|
minStart: z.string().optional(),
|
||||||
|
maxStart: z.string().optional(),
|
||||||
stop: z.string().optional(),
|
stop: z.string().optional(),
|
||||||
|
minStop: z.string().optional(),
|
||||||
|
maxStop: z.string().optional(),
|
||||||
}).describe('DateRangeFilterInput');
|
}).describe('DateRangeFilterInput');
|
||||||
|
|
||||||
export const FilterInputSchema = z.union([
|
export const FilterInputSchema = z.union([
|
||||||
@@ -95,7 +99,9 @@ export type CheckboxFilterInput = z.infer<typeof CheckboxFilterInputSchema>;
|
|||||||
|
|
||||||
export type FilterInput = z.infer<typeof FilterInputSchema>;
|
export type FilterInput = z.infer<typeof FilterInputSchema>;
|
||||||
|
|
||||||
export type CheckboxFilterInputOption = z.infer<typeof CheckboxFilterInputOptionSchema>;
|
export type CheckboxFilterInputOption = z.infer<
|
||||||
|
typeof CheckboxFilterInputOptionSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export type DateRangeFilterInput = z.infer<typeof DateRangeFilterInputSchema>;
|
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 flex bg-isa-white w-[18.375rem] rounded-[1.25rem] shadow-[0px_0px_16px_0px_rgba(0,0,0,0.15)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = input.start ? new Date(input.start) : undefined;
|
||||||
|
const stopDate = input.stop ? new Date(input.stop) : undefined;
|
||||||
|
|
||||||
|
this.datepicker.setValue([startDate, stopDate]);
|
||||||
|
this.datepicker.updateValueAndValidity();
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
this.valueChanges();
|
||||||
|
untracked(() => {
|
||||||
|
console.log('Datepicker Value Changes', {
|
||||||
|
values: this.datepicker?.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './datepicker-range-input.component';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export * from './search-bar-input';
|
export * from './search-bar-input';
|
||||||
export * from './checkbox-input';
|
export * from './checkbox-input';
|
||||||
export * from './datepicker-input';
|
export * from './datepicker-range-input';
|
||||||
export * from './input-renderer';
|
export * from './input-renderer';
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
@switch (filterInput().type) {
|
@switch (filterInput().type) {
|
||||||
@case (InputType.Checkbox) {
|
@case (InputType.Checkbox) {
|
||||||
<filter-checkbox-input [inputKey]="filterInput().key"> </filter-checkbox-input>
|
<filter-checkbox-input [inputKey]="filterInput().key">
|
||||||
|
</filter-checkbox-input>
|
||||||
}
|
}
|
||||||
@case (InputType.DateRange) {
|
@case (InputType.DateRange) {
|
||||||
<filter-datepicker-input [inputKey]="filterInput().key"> </filter-datepicker-input>
|
<filter-datepicker-range-input [inputKey]="filterInput().key">
|
||||||
|
</filter-datepicker-range-input>
|
||||||
}
|
}
|
||||||
@default {
|
@default {
|
||||||
<div class="text-isa-accent-red isa-text-body-1-bold">
|
<div class="text-isa-accent-red isa-text-body-1-bold">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ViewEncapsulation,
|
ViewEncapsulation,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CheckboxInputComponent } from '../checkbox-input';
|
import { CheckboxInputComponent } from '../checkbox-input';
|
||||||
import { DatepickerInputComponent } from '../datepicker-input';
|
import { DatepickerRangeInputComponent } from '../datepicker-range-input';
|
||||||
import { FilterInput } from '../../core';
|
import { FilterInput } from '../../core';
|
||||||
import { InputType } from '../../types';
|
import { InputType } from '../../types';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ import { InputType } from '../../types';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
encapsulation: ViewEncapsulation.None,
|
encapsulation: ViewEncapsulation.None,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CheckboxInputComponent, DatepickerInputComponent],
|
imports: [CheckboxInputComponent, DatepickerRangeInputComponent],
|
||||||
host: {
|
host: {
|
||||||
'[class]': "['filter-input-renderer']",
|
'[class]': "['filter-input-renderer']",
|
||||||
},
|
},
|
||||||
|
|||||||
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 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-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 h-[29.5rem] w-[18.375rem] inline-grid grid-rows-[4.5rem,25rem] 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-center justify-center border-b-[0.0625rem] border-isa-neutral-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-[1.33rem] 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,49 @@
|
|||||||
|
<div class="flex flex-row justify-between items-center pl-[1.475rem] pr-4 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="0.75rem" 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
libs/ui/datepicker/src/lib/datepicker-base.spec.ts
Normal file
88
libs/ui/datepicker/src/lib/datepicker-base.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
98
libs/ui/datepicker/src/lib/datepicker-base.ts
Normal file
98
libs/ui/datepicker/src/lib/datepicker-base.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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: DateValue | 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);
|
||||||
|
} 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,135 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
// 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/checkbox/checkbox';
|
||||||
@use "./lib/chips/chips";
|
@use './lib/chips/chips';
|
||||||
@use "./lib/dropdown/dropdown";
|
@use './lib/dropdown/dropdown';
|
||||||
@use "./lib/text-field/text-field.scss";
|
@use './lib/text-field/text-field.scss';
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -31,6 +31,7 @@
|
|||||||
"@ngrx/store-devtools": "19.1.0",
|
"@ngrx/store-devtools": "19.1.0",
|
||||||
"angular-oauth2-oidc": "^17.0.2",
|
"angular-oauth2-oidc": "^17.0.2",
|
||||||
"angular-oauth2-oidc-jwks": "^17.0.2",
|
"angular-oauth2-oidc-jwks": "^17.0.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"ng2-pdf-viewer": "^10.4.0",
|
"ng2-pdf-viewer": "^10.4.0",
|
||||||
@@ -16976,6 +16977,16 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/date-format": {
|
||||||
"version": "4.0.14",
|
"version": "4.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"@ngrx/store-devtools": "19.1.0",
|
"@ngrx/store-devtools": "19.1.0",
|
||||||
"angular-oauth2-oidc": "^17.0.2",
|
"angular-oauth2-oidc": "^17.0.2",
|
||||||
"angular-oauth2-oidc-jwks": "^17.0.2",
|
"angular-oauth2-oidc-jwks": "^17.0.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"ng2-pdf-viewer": "^10.4.0",
|
"ng2-pdf-viewer": "^10.4.0",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"@isa/shared/filter": ["libs/shared/filter/src/index.ts"],
|
"@isa/shared/filter": ["libs/shared/filter/src/index.ts"],
|
||||||
"@isa/shared/product-image": ["libs/shared/product-image/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/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/empty-state": ["libs/ui/empty-state/src/index.ts"],
|
||||||
"@isa/ui/input-controls": ["libs/ui/input-controls/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"],
|
"@isa/ui/item-rows": ["libs/ui/item-rows/src/index.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user