From 3d18e45f599219714f21da2d5bb1b059ad8ba78c Mon Sep 17 00:00:00 2001 From: Nino Righi Date: Thu, 10 Apr 2025 10:06:24 +0000 Subject: [PATCH] Merged PR 1828: #5005 Datepicker UI #5005 Datepicker UI --- .../app/guards/can-activate-goods-in.guard.ts | 9 +- apps/isa-app/src/main.ts | 8 +- apps/isa-app/src/ui.scss | 7 +- .../ui/datepicker/ui-datepicker.stories.ts | 33 +++ .../return-search-main.component.html | 2 +- libs/shared/filter/src/lib/core/mappings.ts | 19 +- libs/shared/filter/src/lib/core/schemas.ts | 8 +- .../datepicker-input.component.html | 1 - .../datepicker-input.component.scss | 3 - .../datepicker-input.component.ts | 37 --- .../src/lib/inputs/datepicker-input/index.ts | 1 - .../datepicker-range-input.component.html | 8 + .../datepicker-range-input.component.scss | 3 + .../datepicker-range-input.component.ts | 75 ++++++ .../inputs/datepicker-range-input/index.ts | 1 + libs/shared/filter/src/lib/inputs/index.ts | 2 +- .../input-renderer.component.html | 6 +- .../input-renderer.component.ts | 4 +- libs/ui/datepicker/README.md | 7 + libs/ui/datepicker/eslint.config.mjs | 34 +++ libs/ui/datepicker/jest.config.ts | 21 ++ libs/ui/datepicker/project.json | 20 ++ libs/ui/datepicker/src/datepicker.scss | 6 + libs/ui/datepicker/src/index.ts | 5 + .../ui/datepicker/src/lib/_calendar-body.scss | 98 ++++++++ .../datepicker/src/lib/_month-year-body.scss | 23 ++ .../datepicker/src/lib/_range-datepicker.scss | 3 + .../ui/datepicker/src/lib/_selected-date.scss | 0 .../src/lib/_selected-month-year.scss | 19 ++ .../datepicker/src/lib/_selected-range.scss | 36 +++ .../calendar-body-cell.directive.spec.ts | 62 +++++ .../calendar-body-cell.directive.ts | 118 +++++++++ .../calendar-body.component.html | 49 ++++ .../calendar-body.component.spec.ts | 223 ++++++++++++++++++ .../calendar-body/calendar-body.component.ts | 150 ++++++++++++ .../month-year-body.component.html | 54 +++++ .../month-year-body.component.spec.ts | 158 +++++++++++++ .../month-year-body.component.ts | 166 +++++++++++++ .../src/lib/datepicker-base.spec.ts | 88 +++++++ libs/ui/datepicker/src/lib/datepicker-base.ts | 98 ++++++++ .../src/lib/datepicker-view.state.ts | 21 ++ .../selected-date.component.html | 0 .../selected-date.component.spec.ts | 22 ++ .../selected-date/selected-date.component.ts | 14 ++ .../selected-month-year.component.html | 27 +++ .../selected-month-year.component.spec.ts | 63 +++++ .../selected-month-year.component.ts | 53 +++++ .../date-input.directive.spec.ts | 120 ++++++++++ .../selected-range/date-input.directive.ts | 135 +++++++++++ .../selected-range.component.html | 27 +++ .../selected-range.component.spec.ts | 103 ++++++++ .../selected-range.component.ts | 115 +++++++++ .../datepicker/src/lib/inject-datepicker.ts | 13 + .../src/lib/range-datepicker.component.html | 7 + .../lib/range-datepicker.component.spec.ts | 83 +++++++ .../src/lib/range-datepicker.component.ts | 83 +++++++ .../src/lib/range-datepicker.spec.ts | 130 ++++++++++ .../ui/datepicker/src/lib/range-datepicker.ts | 62 +++++ libs/ui/datepicker/src/lib/tokens.ts | 29 +++ .../src/lib/types/date-range-value.type.ts | 18 ++ .../src/lib/types/date-value.type.ts | 12 + .../src/lib/types/datepicker-view.type.ts | 15 ++ .../src/lib/types/days-of-week.type.ts | 17 ++ libs/ui/datepicker/src/lib/types/index.ts | 4 + .../validators/date-bounds.validator.spec.ts | 69 ++++++ .../lib/validators/date-bounds.validator.ts | 30 +++ .../date-range-order.validator.spec.ts | 65 +++++ .../validators/date-range-order.validator.ts | 37 +++ libs/ui/datepicker/src/test-setup.ts | 6 + libs/ui/datepicker/tsconfig.json | 28 +++ libs/ui/datepicker/tsconfig.lib.json | 17 ++ libs/ui/datepicker/tsconfig.spec.json | 16 ++ .../ui/input-controls/src/input-controls.scss | 8 +- package-lock.json | 11 + package.json | 1 + tsconfig.base.json | 1 + 76 files changed, 3063 insertions(+), 64 deletions(-) create mode 100644 apps/isa-app/stories/ui/datepicker/ui-datepicker.stories.ts delete mode 100644 libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.html delete mode 100644 libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.scss delete mode 100644 libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.ts delete mode 100644 libs/shared/filter/src/lib/inputs/datepicker-input/index.ts create mode 100644 libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.html create mode 100644 libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.scss create mode 100644 libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.ts create mode 100644 libs/shared/filter/src/lib/inputs/datepicker-range-input/index.ts create mode 100644 libs/ui/datepicker/README.md create mode 100644 libs/ui/datepicker/eslint.config.mjs create mode 100644 libs/ui/datepicker/jest.config.ts create mode 100644 libs/ui/datepicker/project.json create mode 100644 libs/ui/datepicker/src/datepicker.scss create mode 100644 libs/ui/datepicker/src/index.ts create mode 100644 libs/ui/datepicker/src/lib/_calendar-body.scss create mode 100644 libs/ui/datepicker/src/lib/_month-year-body.scss create mode 100644 libs/ui/datepicker/src/lib/_range-datepicker.scss create mode 100644 libs/ui/datepicker/src/lib/_selected-date.scss create mode 100644 libs/ui/datepicker/src/lib/_selected-month-year.scss create mode 100644 libs/ui/datepicker/src/lib/_selected-range.scss create mode 100644 libs/ui/datepicker/src/lib/body/calendar-body/calendar-body-cell.directive.spec.ts create mode 100644 libs/ui/datepicker/src/lib/body/calendar-body/calendar-body-cell.directive.ts create mode 100644 libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.html create mode 100644 libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.spec.ts create mode 100644 libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.ts create mode 100644 libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.html create mode 100644 libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.spec.ts create mode 100644 libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.ts create mode 100644 libs/ui/datepicker/src/lib/datepicker-base.spec.ts create mode 100644 libs/ui/datepicker/src/lib/datepicker-base.ts create mode 100644 libs/ui/datepicker/src/lib/datepicker-view.state.ts create mode 100644 libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.html create mode 100644 libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.spec.ts create mode 100644 libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.ts create mode 100644 libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.html create mode 100644 libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.spec.ts create mode 100644 libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.ts create mode 100644 libs/ui/datepicker/src/lib/header/selected-range/date-input.directive.spec.ts create mode 100644 libs/ui/datepicker/src/lib/header/selected-range/date-input.directive.ts create mode 100644 libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.html create mode 100644 libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.spec.ts create mode 100644 libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.ts create mode 100644 libs/ui/datepicker/src/lib/inject-datepicker.ts create mode 100644 libs/ui/datepicker/src/lib/range-datepicker.component.html create mode 100644 libs/ui/datepicker/src/lib/range-datepicker.component.spec.ts create mode 100644 libs/ui/datepicker/src/lib/range-datepicker.component.ts create mode 100644 libs/ui/datepicker/src/lib/range-datepicker.spec.ts create mode 100644 libs/ui/datepicker/src/lib/range-datepicker.ts create mode 100644 libs/ui/datepicker/src/lib/tokens.ts create mode 100644 libs/ui/datepicker/src/lib/types/date-range-value.type.ts create mode 100644 libs/ui/datepicker/src/lib/types/date-value.type.ts create mode 100644 libs/ui/datepicker/src/lib/types/datepicker-view.type.ts create mode 100644 libs/ui/datepicker/src/lib/types/days-of-week.type.ts create mode 100644 libs/ui/datepicker/src/lib/types/index.ts create mode 100644 libs/ui/datepicker/src/lib/validators/date-bounds.validator.spec.ts create mode 100644 libs/ui/datepicker/src/lib/validators/date-bounds.validator.ts create mode 100644 libs/ui/datepicker/src/lib/validators/date-range-order.validator.spec.ts create mode 100644 libs/ui/datepicker/src/lib/validators/date-range-order.validator.ts create mode 100644 libs/ui/datepicker/src/test-setup.ts create mode 100644 libs/ui/datepicker/tsconfig.json create mode 100644 libs/ui/datepicker/tsconfig.lib.json create mode 100644 libs/ui/datepicker/tsconfig.spec.json diff --git a/apps/isa-app/src/app/guards/can-activate-goods-in.guard.ts b/apps/isa-app/src/app/guards/can-activate-goods-in.guard.ts index a8fdc4b20..24533cc01 100644 --- a/apps/isa-app/src/app/guards/can-activate-goods-in.guard.ts +++ b/apps/isa-app/src/app/guards/can-activate-goods-in.guard.ts @@ -14,7 +14,10 @@ export class CanActivateGoodsInGuard { async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { const pid = this._config.get('process.ids.goodsIn', z.number()); - const process = await this._applicationService.getProcessById$(pid).pipe(first()).toPromise(); + const process = await this._applicationService + .getProcessById$(pid) + .pipe(first()) + .toPromise(); if (!process) { await this._applicationService.createProcess({ id: this._config.get('process.ids.goodsIn'), @@ -23,7 +26,9 @@ export class CanActivateGoodsInGuard { name: '', }); } - this._applicationService.activateProcess(this._config.get('process.ids.goodsIn')); + this._applicationService.activateProcess( + this._config.get('process.ids.goodsIn'), + ); return true; } } diff --git a/apps/isa-app/src/main.ts b/apps/isa-app/src/main.ts index 783e3cb2c..8ef65e650 100644 --- a/apps/isa-app/src/main.ts +++ b/apps/isa-app/src/main.ts @@ -1,8 +1,11 @@ import { enableProdMode, isDevMode } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { CONFIG_DATA } from '@isa/core/config'; +import { setDefaultOptions } from 'date-fns'; +import { de } from 'date-fns/locale'; import * as moment from 'moment'; +setDefaultOptions({ locale: de }); moment.locale('de'); import { AppModule } from './app/app.module'; @@ -16,7 +19,9 @@ async function bootstrap() { const config = await configRes.json(); - platformBrowserDynamic([{ provide: CONFIG_DATA, useValue: config }]).bootstrapModule(AppModule); + platformBrowserDynamic([ + { provide: CONFIG_DATA, useValue: config }, + ]).bootstrapModule(AppModule); } try { @@ -24,4 +29,3 @@ try { } catch (error) { console.error(error); } - diff --git a/apps/isa-app/src/ui.scss b/apps/isa-app/src/ui.scss index 4d17d9864..bcaf3852c 100644 --- a/apps/isa-app/src/ui.scss +++ b/apps/isa-app/src/ui.scss @@ -1,3 +1,4 @@ -@use "../../../libs/ui/buttons/src/buttons.scss"; -@use "../../../libs/ui/input-controls/src/input-controls.scss"; -@use "../../../libs/ui/progress-bar/src/lib/progress-bar.scss"; +@use '../../../libs/ui/buttons/src/buttons.scss'; +@use '../../../libs/ui/datepicker/src/datepicker.scss'; +@use '../../../libs/ui/input-controls/src/input-controls.scss'; +@use '../../../libs/ui/progress-bar/src/lib/progress-bar.scss'; diff --git a/apps/isa-app/stories/ui/datepicker/ui-datepicker.stories.ts b/apps/isa-app/stories/ui/datepicker/ui-datepicker.stories.ts new file mode 100644 index 000000000..a1bd4b83a --- /dev/null +++ b/apps/isa-app/stories/ui/datepicker/ui-datepicker.stories.ts @@ -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 = { + 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; + +export const Default: Story = { + args: { + value: [new Date(), new Date(Date.now() + 5 * 24 * 60 * 60 * 1000)], // Start: today, End: 5 days after today + }, +}; diff --git a/libs/oms/feature/return-search/src/lib/return-search-main/return-search-main.component.html b/libs/oms/feature/return-search/src/lib/return-search-main/return-search-main.component.html index 0e169f790..02abaedee 100644 --- a/libs/oms/feature/return-search/src/lib/return-search-main/return-search-main.component.html +++ b/libs/oms/feature/return-search/src/lib/return-search-main/return-search-main.component.html @@ -19,7 +19,7 @@ } -
+
@for (filterInput of filterInputs(); track filterInput.key) { diff --git a/libs/shared/filter/src/lib/core/mappings.ts b/libs/shared/filter/src/lib/core/mappings.ts index 806bf67bd..17860730b 100644 --- a/libs/shared/filter/src/lib/core/mappings.ts +++ b/libs/shared/filter/src/lib/core/mappings.ts @@ -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({ group, key: input.key, @@ -93,8 +96,9 @@ function mapToCheckboxFilterInput(group: string, input: Input): CheckboxFilterIn maxOptions: input.options?.max, options: input.options?.values?.map(mapToCheckboxOption), 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({ group, key: input.key, @@ -113,6 +120,10 @@ function mapToDateRangeFilterInput(group: string, input: Input): DateRangeFilter description: input.description, type: InputType.DateRange, start: input.options?.values?.[0].value, + minStart: input.options?.values?.[0].minValue, + maxStart: input.options?.values?.[0].maxValue, stop: input.options?.values?.[1].value, + minStop: input.options?.values?.[1].minValue, + maxStop: input.options?.values?.[1].maxValue, }); } diff --git a/libs/shared/filter/src/lib/core/schemas.ts b/libs/shared/filter/src/lib/core/schemas.ts index 8e50228d3..d3fdc280f 100644 --- a/libs/shared/filter/src/lib/core/schemas.ts +++ b/libs/shared/filter/src/lib/core/schemas.ts @@ -43,7 +43,11 @@ export const CheckboxFilterInputSchema = BaseFilterInputSchema.extend({ export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({ type: z.literal(InputType.DateRange), start: z.string().optional(), + minStart: z.string().optional(), + maxStart: z.string().optional(), stop: z.string().optional(), + minStop: z.string().optional(), + maxStop: z.string().optional(), }).describe('DateRangeFilterInput'); export const FilterInputSchema = z.union([ @@ -95,7 +99,9 @@ export type CheckboxFilterInput = z.infer; export type FilterInput = z.infer; -export type CheckboxFilterInputOption = z.infer; +export type CheckboxFilterInputOption = z.infer< + typeof CheckboxFilterInputOptionSchema +>; export type DateRangeFilterInput = z.infer; diff --git a/libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.html b/libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.html deleted file mode 100644 index 54b1f2554..000000000 --- a/libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.html +++ /dev/null @@ -1 +0,0 @@ -

📅📅📅

diff --git a/libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.scss b/libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.scss deleted file mode 100644 index 89196f941..000000000 --- a/libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.scss +++ /dev/null @@ -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)]; -} diff --git a/libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.ts b/libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.ts deleted file mode 100644 index 8610a1904..000000000 --- a/libs/shared/filter/src/lib/inputs/datepicker-input/datepicker-input.component.ts +++ /dev/null @@ -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(); - - datepicker = new FormControl({}); - valueChanges = toSignal(this.datepicker.valueChanges); - - constructor() { - effect(() => { - this.valueChanges(); - untracked(() => { - console.log({ startTest: '2021-01-01', stopTest: '2021-12-31' }); - }); - }); - } -} diff --git a/libs/shared/filter/src/lib/inputs/datepicker-input/index.ts b/libs/shared/filter/src/lib/inputs/datepicker-input/index.ts deleted file mode 100644 index 54a05325e..000000000 --- a/libs/shared/filter/src/lib/inputs/datepicker-input/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './datepicker-input.component'; diff --git a/libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.html b/libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.html new file mode 100644 index 000000000..eaaa5289f --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.html @@ -0,0 +1,8 @@ +@let inp = input(); +@if (inp) { + +} diff --git a/libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.scss b/libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.scss new file mode 100644 index 000000000..f9afc43d2 --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.scss @@ -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)]; +} diff --git a/libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.ts b/libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.ts new file mode 100644 index 000000000..f4077bcfa --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/datepicker-range-input/datepicker-range-input.component.ts @@ -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(); + + datepicker = new FormControl(undefined); + valueChanges = toSignal(this.datepicker.valueChanges); + + input = computed(() => { + 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(() => { + const inp = this.input(); + return inp.minStart ? new Date(inp.minStart) : undefined; + }); + + datepickerMax = computed(() => { + 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, + }); + }); + }); + } +} diff --git a/libs/shared/filter/src/lib/inputs/datepicker-range-input/index.ts b/libs/shared/filter/src/lib/inputs/datepicker-range-input/index.ts new file mode 100644 index 000000000..c6cbce16b --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/datepicker-range-input/index.ts @@ -0,0 +1 @@ +export * from './datepicker-range-input.component'; diff --git a/libs/shared/filter/src/lib/inputs/index.ts b/libs/shared/filter/src/lib/inputs/index.ts index 0357f2714..d7699d25d 100644 --- a/libs/shared/filter/src/lib/inputs/index.ts +++ b/libs/shared/filter/src/lib/inputs/index.ts @@ -1,4 +1,4 @@ export * from './search-bar-input'; export * from './checkbox-input'; -export * from './datepicker-input'; +export * from './datepicker-range-input'; export * from './input-renderer'; diff --git a/libs/shared/filter/src/lib/inputs/input-renderer/input-renderer.component.html b/libs/shared/filter/src/lib/inputs/input-renderer/input-renderer.component.html index cbf1371f6..f592087ee 100644 --- a/libs/shared/filter/src/lib/inputs/input-renderer/input-renderer.component.html +++ b/libs/shared/filter/src/lib/inputs/input-renderer/input-renderer.component.html @@ -1,9 +1,11 @@ @switch (filterInput().type) { @case (InputType.Checkbox) { - + + } @case (InputType.DateRange) { - + + } @default {
diff --git a/libs/shared/filter/src/lib/inputs/input-renderer/input-renderer.component.ts b/libs/shared/filter/src/lib/inputs/input-renderer/input-renderer.component.ts index 91268f603..60735c241 100644 --- a/libs/shared/filter/src/lib/inputs/input-renderer/input-renderer.component.ts +++ b/libs/shared/filter/src/lib/inputs/input-renderer/input-renderer.component.ts @@ -5,7 +5,7 @@ import { ViewEncapsulation, } from '@angular/core'; import { CheckboxInputComponent } from '../checkbox-input'; -import { DatepickerInputComponent } from '../datepicker-input'; +import { DatepickerRangeInputComponent } from '../datepicker-range-input'; import { FilterInput } from '../../core'; import { InputType } from '../../types'; @@ -16,7 +16,7 @@ import { InputType } from '../../types'; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, standalone: true, - imports: [CheckboxInputComponent, DatepickerInputComponent], + imports: [CheckboxInputComponent, DatepickerRangeInputComponent], host: { '[class]': "['filter-input-renderer']", }, diff --git a/libs/ui/datepicker/README.md b/libs/ui/datepicker/README.md new file mode 100644 index 000000000..1a1418621 --- /dev/null +++ b/libs/ui/datepicker/README.md @@ -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. diff --git a/libs/ui/datepicker/eslint.config.mjs b/libs/ui/datepicker/eslint.config.mjs new file mode 100644 index 000000000..c68787af3 --- /dev/null +++ b/libs/ui/datepicker/eslint.config.mjs @@ -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: {}, + }, +]; diff --git a/libs/ui/datepicker/jest.config.ts b/libs/ui/datepicker/jest.config.ts new file mode 100644 index 000000000..41f747db0 --- /dev/null +++ b/libs/ui/datepicker/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'ui-datepicker', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/ui/datepicker', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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', + ], +}; diff --git a/libs/ui/datepicker/project.json b/libs/ui/datepicker/project.json new file mode 100644 index 000000000..04526cff4 --- /dev/null +++ b/libs/ui/datepicker/project.json @@ -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" + } + } +} diff --git a/libs/ui/datepicker/src/datepicker.scss b/libs/ui/datepicker/src/datepicker.scss new file mode 100644 index 000000000..dd43ffadb --- /dev/null +++ b/libs/ui/datepicker/src/datepicker.scss @@ -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'; diff --git a/libs/ui/datepicker/src/index.ts b/libs/ui/datepicker/src/index.ts new file mode 100644 index 000000000..d581c06a8 --- /dev/null +++ b/libs/ui/datepicker/src/index.ts @@ -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'; diff --git a/libs/ui/datepicker/src/lib/_calendar-body.scss b/libs/ui/datepicker/src/lib/_calendar-body.scss new file mode 100644 index 000000000..175abef8b --- /dev/null +++ b/libs/ui/datepicker/src/lib/_calendar-body.scss @@ -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; + } +} diff --git a/libs/ui/datepicker/src/lib/_month-year-body.scss b/libs/ui/datepicker/src/lib/_month-year-body.scss new file mode 100644 index 000000000..d0d68899a --- /dev/null +++ b/libs/ui/datepicker/src/lib/_month-year-body.scss @@ -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]; + } +} diff --git a/libs/ui/datepicker/src/lib/_range-datepicker.scss b/libs/ui/datepicker/src/lib/_range-datepicker.scss new file mode 100644 index 000000000..525aec574 --- /dev/null +++ b/libs/ui/datepicker/src/lib/_range-datepicker.scss @@ -0,0 +1,3 @@ +.ui-range-datepicker { + @apply h-[29.5rem] w-[18.375rem] inline-grid grid-rows-[4.5rem,25rem] font-sans; +} diff --git a/libs/ui/datepicker/src/lib/_selected-date.scss b/libs/ui/datepicker/src/lib/_selected-date.scss new file mode 100644 index 000000000..e69de29bb diff --git a/libs/ui/datepicker/src/lib/_selected-month-year.scss b/libs/ui/datepicker/src/lib/_selected-month-year.scss new file mode 100644 index 000000000..2fc4035b8 --- /dev/null +++ b/libs/ui/datepicker/src/lib/_selected-month-year.scss @@ -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]; + } +} diff --git a/libs/ui/datepicker/src/lib/_selected-range.scss b/libs/ui/datepicker/src/lib/_selected-range.scss new file mode 100644 index 000000000..baab198fc --- /dev/null +++ b/libs/ui/datepicker/src/lib/_selected-range.scss @@ -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; + } + } +} diff --git a/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body-cell.directive.spec.ts b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body-cell.directive.spec.ts new file mode 100644 index 000000000..209dcab97 --- /dev/null +++ b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body-cell.directive.spec.ts @@ -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; + 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( + `
`, + ); + expect(spectator.directive.isToday()).toBe(true); + }); + + it('should mark isToday false when cell date is not today', () => { + spectator = createDirective( + `
`, + ); + expect(spectator.directive.isToday()).toBe(false); + }); + + it('should return false for isDisabled when cell date is within min and max', () => { + spectator = createDirective( + `
`, + ); + datepickerMock.min.mockReturnValue(undefined); + datepickerMock.max.mockReturnValue(undefined); + spectator.detectChanges(); + expect(spectator.directive.isDisabled()).toBe(false); + }); +}); diff --git a/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body-cell.directive.ts b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body-cell.directive.ts new file mode 100644 index 000000000..4428f0dd1 --- /dev/null +++ b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body-cell.directive.ts @@ -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({ alias: 'uiCalendarBodyCell' }); + + /** + * Computed property that returns true if the current day is today. + */ + isToday = computed(() => 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(() => { + 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(() => { + 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); + } +} diff --git a/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.html b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.html new file mode 100644 index 000000000..b24125e39 --- /dev/null +++ b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.html @@ -0,0 +1,49 @@ +
+ + +
+ + + +
+
+ +
+
+ @for (dayNameShort of daysOfWeek(); let i = $index; track i) { +
+ {{ dayNameShort }} +
+ } +
+ +
+ @for (day of calendarDays(); let i = $index; track i) { + + } +
+
+ + diff --git a/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.spec.ts b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.spec.ts new file mode 100644 index 000000000..32ad46486 --- /dev/null +++ b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.spec.ts @@ -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; + 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(); + }); + }); +}); diff --git a/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.ts b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.ts new file mode 100644 index 000000000..6e3e9f030 --- /dev/null +++ b/libs/ui/datepicker/src/lib/body/calendar-body/calendar-body.component.ts @@ -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 = 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), + ); + } +} diff --git a/libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.html b/libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.html new file mode 100644 index 000000000..3b10620d9 --- /dev/null +++ b/libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.html @@ -0,0 +1,54 @@ +
    + @if (viewState.displayedView() === 'month') { + @for (month of months(); track month.label) { + + } + } + + @if (viewState.displayedView() === 'year') { + @for (year of years(); track year) { + + } + } +
+ +
+ + + +
diff --git a/libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.spec.ts b/libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.spec.ts new file mode 100644 index 000000000..ce8906da0 --- /dev/null +++ b/libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.spec.ts @@ -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; + 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); + }); +}); diff --git a/libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.ts b/libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.ts new file mode 100644 index 000000000..36bdcb35d --- /dev/null +++ b/libs/ui/datepicker/src/lib/body/month-year-body/month-year-body.component.ts @@ -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); + } +} diff --git a/libs/ui/datepicker/src/lib/datepicker-base.spec.ts b/libs/ui/datepicker/src/lib/datepicker-base.spec.ts new file mode 100644 index 000000000..b9bc7287f --- /dev/null +++ b/libs/ui/datepicker/src/lib/datepicker-base.spec.ts @@ -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 { + 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; + 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); + }); + }); +}); diff --git a/libs/ui/datepicker/src/lib/datepicker-base.ts b/libs/ui/datepicker/src/lib/datepicker-base.ts new file mode 100644 index 000000000..1105ddaa9 --- /dev/null +++ b/libs/ui/datepicker/src/lib/datepicker-base.ts @@ -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 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(); + + /** + * The minimum allowed date for the datepicker. + * Injected using the UI_DATEPICKER_DEFAULT_MIN token. + */ + min = input(inject(UI_DATEPICKER_DEFAULT_MIN)); + + /** + * The maximum allowed date for the datepicker. + * Injected using the UI_DATEPICKER_DEFAULT_MAX token. + */ + max = input(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; + } +} diff --git a/libs/ui/datepicker/src/lib/datepicker-view.state.ts b/libs/ui/datepicker/src/lib/datepicker-view.state.ts new file mode 100644 index 000000000..21cc995aa --- /dev/null +++ b/libs/ui/datepicker/src/lib/datepicker-view.state.ts @@ -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.Day); + + /** + * Signal representing the currently displayed date in the datepicker. + * Initialized with the current date. + */ + readonly displayedDate = signal(new Date()); +} diff --git a/libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.html b/libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.html new file mode 100644 index 000000000..e69de29bb diff --git a/libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.spec.ts b/libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.spec.ts new file mode 100644 index 000000000..2259b13f4 --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.spec.ts @@ -0,0 +1,22 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator'; +import { SelectedDateComponent } from './selected-date.component'; + +describe('SelectedDateComponent', () => { + let spectator: Spectator; + 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'); + }); +}); diff --git a/libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.ts b/libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.ts new file mode 100644 index 000000000..df728ae49 --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-date/selected-date.component.ts @@ -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 +} diff --git a/libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.html b/libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.html new file mode 100644 index 000000000..9ff2cb1e3 --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.html @@ -0,0 +1,27 @@ + + + diff --git a/libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.spec.ts b/libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.spec.ts new file mode 100644 index 000000000..81de0389c --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.spec.ts @@ -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; + +const createComponent = createComponentFactory({ + component: SelectedMonthYearComponent, + providers: [ + { + provide: DatepickerViewState, + useValue: fakeViewState, + }, + ], +}); + +describe('SelectedMonthYearComponent', () => { + let spectator: Spectator; + + 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); + }); +}); diff --git a/libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.ts b/libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.ts new file mode 100644 index 000000000..0f30cfc32 --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-month-year/selected-month-year.component.ts @@ -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'), + ); +} diff --git a/libs/ui/datepicker/src/lib/header/selected-range/date-input.directive.spec.ts b/libs/ui/datepicker/src/lib/header/selected-range/date-input.directive.spec.ts new file mode 100644 index 000000000..1d17d090b --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-range/date-input.directive.spec.ts @@ -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; + const createDirective = createDirectiveFactory({ + directive: DateInputDirective, + imports: [FormsModule], + }); + + it('should render formatted date on writeValue', () => { + // Arrange + spectator = createDirective(``); + 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(``); + + // 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(``); + 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(``); + 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(``); + 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(``); + 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(``); + 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'); + }); +}); diff --git a/libs/ui/datepicker/src/lib/header/selected-range/date-input.directive.ts b/libs/ui/datepicker/src/lib/header/selected-range/date-input.directive.ts new file mode 100644 index 000000000..ce4b13a9e --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-range/date-input.directive.ts @@ -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(); + } +} diff --git a/libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.html b/libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.html new file mode 100644 index 000000000..47d128262 --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.html @@ -0,0 +1,27 @@ +
+
+ + +
+
+ +
+ + +
+
+
diff --git a/libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.spec.ts b/libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.spec.ts new file mode 100644 index 000000000..fa0324f22 --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.spec.ts @@ -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; + let component: SelectedRangeComponent; + let rangeDatepickerMock: jest.Mocked; + + 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; + + 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, + ]); + }); +}); diff --git a/libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.ts b/libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.ts new file mode 100644 index 000000000..cf8b61820 --- /dev/null +++ b/libs/ui/datepicker/src/lib/header/selected-range/selected-range.component.ts @@ -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 = 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, + ]); + } + }); + }); +} diff --git a/libs/ui/datepicker/src/lib/inject-datepicker.ts b/libs/ui/datepicker/src/lib/inject-datepicker.ts new file mode 100644 index 000000000..40e51d29a --- /dev/null +++ b/libs/ui/datepicker/src/lib/inject-datepicker.ts @@ -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); +} diff --git a/libs/ui/datepicker/src/lib/range-datepicker.component.html b/libs/ui/datepicker/src/lib/range-datepicker.component.html new file mode 100644 index 000000000..b5d646a0a --- /dev/null +++ b/libs/ui/datepicker/src/lib/range-datepicker.component.html @@ -0,0 +1,7 @@ +@if (viewState.displayedView() === 'day') { + + +} @else { + + +} diff --git a/libs/ui/datepicker/src/lib/range-datepicker.component.spec.ts b/libs/ui/datepicker/src/lib/range-datepicker.component.spec.ts new file mode 100644 index 000000000..8733cf7bf --- /dev/null +++ b/libs/ui/datepicker/src/lib/range-datepicker.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/libs/ui/datepicker/src/lib/range-datepicker.component.ts b/libs/ui/datepicker/src/lib/range-datepicker.component.ts new file mode 100644 index 000000000..2d0dffe51 --- /dev/null +++ b/libs/ui/datepicker/src/lib/range-datepicker.component.ts @@ -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(); + } +} diff --git a/libs/ui/datepicker/src/lib/range-datepicker.spec.ts b/libs/ui/datepicker/src/lib/range-datepicker.spec.ts new file mode 100644 index 000000000..eb0516484 --- /dev/null +++ b/libs/ui/datepicker/src/lib/range-datepicker.spec.ts @@ -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; + 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); + }); + }); +}); diff --git a/libs/ui/datepicker/src/lib/range-datepicker.ts b/libs/ui/datepicker/src/lib/range-datepicker.ts new file mode 100644 index 000000000..906f72c03 --- /dev/null +++ b/libs/ui/datepicker/src/lib/range-datepicker.ts @@ -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 { + /** + * 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]; + } +} diff --git a/libs/ui/datepicker/src/lib/tokens.ts b/libs/ui/datepicker/src/lib/tokens.ts new file mode 100644 index 000000000..94a36591a --- /dev/null +++ b/libs/ui/datepicker/src/lib/tokens.ts @@ -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} + */ +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} + */ +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 +}); diff --git a/libs/ui/datepicker/src/lib/types/date-range-value.type.ts b/libs/ui/datepicker/src/lib/types/date-range-value.type.ts new file mode 100644 index 000000000..26b5f2b0b --- /dev/null +++ b/libs/ui/datepicker/src/lib/types/date-range-value.type.ts @@ -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; diff --git a/libs/ui/datepicker/src/lib/types/date-value.type.ts b/libs/ui/datepicker/src/lib/types/date-value.type.ts new file mode 100644 index 000000000..f42c2112e --- /dev/null +++ b/libs/ui/datepicker/src/lib/types/date-value.type.ts @@ -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; diff --git a/libs/ui/datepicker/src/lib/types/datepicker-view.type.ts b/libs/ui/datepicker/src/lib/types/datepicker-view.type.ts new file mode 100644 index 000000000..0ef2a4e16 --- /dev/null +++ b/libs/ui/datepicker/src/lib/types/datepicker-view.type.ts @@ -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]; diff --git a/libs/ui/datepicker/src/lib/types/days-of-week.type.ts b/libs/ui/datepicker/src/lib/types/days-of-week.type.ts new file mode 100644 index 000000000..53c41479e --- /dev/null +++ b/libs/ui/datepicker/src/lib/types/days-of-week.type.ts @@ -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]; diff --git a/libs/ui/datepicker/src/lib/types/index.ts b/libs/ui/datepicker/src/lib/types/index.ts new file mode 100644 index 000000000..aed6480c3 --- /dev/null +++ b/libs/ui/datepicker/src/lib/types/index.ts @@ -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'; diff --git a/libs/ui/datepicker/src/lib/validators/date-bounds.validator.spec.ts b/libs/ui/datepicker/src/lib/validators/date-bounds.validator.spec.ts new file mode 100644 index 000000000..8845bf5ab --- /dev/null +++ b/libs/ui/datepicker/src/lib/validators/date-bounds.validator.spec.ts @@ -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(); + }); +}); diff --git a/libs/ui/datepicker/src/lib/validators/date-bounds.validator.ts b/libs/ui/datepicker/src/lib/validators/date-bounds.validator.ts new file mode 100644 index 000000000..d753412e3 --- /dev/null +++ b/libs/ui/datepicker/src/lib/validators/date-bounds.validator.ts @@ -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; + }; +} diff --git a/libs/ui/datepicker/src/lib/validators/date-range-order.validator.spec.ts b/libs/ui/datepicker/src/lib/validators/date-range-order.validator.spec.ts new file mode 100644 index 000000000..43987ed4c --- /dev/null +++ b/libs/ui/datepicker/src/lib/validators/date-range-order.validator.spec.ts @@ -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(); + }); +}); diff --git a/libs/ui/datepicker/src/lib/validators/date-range-order.validator.ts b/libs/ui/datepicker/src/lib/validators/date-range-order.validator.ts new file mode 100644 index 000000000..10541d5a4 --- /dev/null +++ b/libs/ui/datepicker/src/lib/validators/date-range-order.validator.ts @@ -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; + }; +} diff --git a/libs/ui/datepicker/src/test-setup.ts b/libs/ui/datepicker/src/test-setup.ts new file mode 100644 index 000000000..ea414013f --- /dev/null +++ b/libs/ui/datepicker/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/ui/datepicker/tsconfig.json b/libs/ui/datepicker/tsconfig.json new file mode 100644 index 000000000..fde35eab0 --- /dev/null +++ b/libs/ui/datepicker/tsconfig.json @@ -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 + } +} diff --git a/libs/ui/datepicker/tsconfig.lib.json b/libs/ui/datepicker/tsconfig.lib.json new file mode 100644 index 000000000..9b49be758 --- /dev/null +++ b/libs/ui/datepicker/tsconfig.lib.json @@ -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"] +} diff --git a/libs/ui/datepicker/tsconfig.spec.json b/libs/ui/datepicker/tsconfig.spec.json new file mode 100644 index 000000000..f858ef78c --- /dev/null +++ b/libs/ui/datepicker/tsconfig.spec.json @@ -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" + ] +} diff --git a/libs/ui/input-controls/src/input-controls.scss b/libs/ui/input-controls/src/input-controls.scss index 440c47cc5..eed9852c2 100644 --- a/libs/ui/input-controls/src/input-controls.scss +++ b/libs/ui/input-controls/src/input-controls.scss @@ -1,4 +1,4 @@ -@use "./lib/checkbox/checkbox"; -@use "./lib/chips/chips"; -@use "./lib/dropdown/dropdown"; -@use "./lib/text-field/text-field.scss"; +@use './lib/checkbox/checkbox'; +@use './lib/chips/chips'; +@use './lib/dropdown/dropdown'; +@use './lib/text-field/text-field.scss'; diff --git a/package-lock.json b/package-lock.json index dad91c01e..c72ca5817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@ngrx/store-devtools": "19.1.0", "angular-oauth2-oidc": "^17.0.2", "angular-oauth2-oidc-jwks": "^17.0.2", + "date-fns": "^4.1.0", "lodash": "^4.17.21", "moment": "^2.30.1", "ng2-pdf-viewer": "^10.4.0", @@ -16976,6 +16977,16 @@ "node": ">=12" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/date-format": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", diff --git a/package.json b/package.json index 15560e561..9aca7d722 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@ngrx/store-devtools": "19.1.0", "angular-oauth2-oidc": "^17.0.2", "angular-oauth2-oidc-jwks": "^17.0.2", + "date-fns": "^4.1.0", "lodash": "^4.17.21", "moment": "^2.30.1", "ng2-pdf-viewer": "^10.4.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index 5146139a3..1dfeea307 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -61,6 +61,7 @@ "@isa/shared/filter": ["libs/shared/filter/src/index.ts"], "@isa/shared/product-image": ["libs/shared/product-image/src/index.ts"], "@isa/ui/buttons": ["libs/ui/buttons/src/index.ts"], + "@isa/ui/datepicker": ["libs/ui/datepicker/src/index.ts"], "@isa/ui/empty-state": ["libs/ui/empty-state/src/index.ts"], "@isa/ui/input-controls": ["libs/ui/input-controls/src/index.ts"], "@isa/ui/item-rows": ["libs/ui/item-rows/src/index.ts"],