diff --git a/packages/components/core/locales/en-US.ts b/packages/components/core/locales/en-US.ts index e40bfcd60..7539996e4 100644 --- a/packages/components/core/locales/en-US.ts +++ b/packages/components/core/locales/en-US.ts @@ -101,6 +101,8 @@ export const enUSLocaleData = { customPeriodFrom: 'from', customPeriodTo: 'to', customPeriodErrorHint: 'The period cannot start later than it ends', + customPeriodMinIntervalErrorHint: 'The period cannot be shorter than {{ value }}', + customPeriodMaxIntervalErrorHint: 'The period cannot be longer than {{ value }}', backToPeriodSelection: 'Back' } }, diff --git a/packages/components/core/locales/es-LA.ts b/packages/components/core/locales/es-LA.ts index ff6368dda..c826f9718 100644 --- a/packages/components/core/locales/es-LA.ts +++ b/packages/components/core/locales/es-LA.ts @@ -100,6 +100,8 @@ export const esLALocaleData = { customPeriodFrom: 'desde', customPeriodTo: 'hasta', customPeriodErrorHint: 'El inicio del período no puede estar después del fin', + customPeriodMinIntervalErrorHint: 'El período no puede ser más corto que {{ value }}', + customPeriodMaxIntervalErrorHint: 'El período no puede ser más largo que {{ value }}', backToPeriodSelection: 'Atrás' } }, diff --git a/packages/components/core/locales/pt-BR.ts b/packages/components/core/locales/pt-BR.ts index 26e23c479..4a3da6216 100644 --- a/packages/components/core/locales/pt-BR.ts +++ b/packages/components/core/locales/pt-BR.ts @@ -100,6 +100,8 @@ export const ptBRLocaleData = { customPeriodFrom: 'de', customPeriodTo: 'até', customPeriodErrorHint: 'O período não pode começar depois de quando acaba', + customPeriodMinIntervalErrorHint: 'O período não pode ser menor que {{ value }}', + customPeriodMaxIntervalErrorHint: 'O período não pode ser maior que {{ value }}', backToPeriodSelection: 'Voltar' } }, diff --git a/packages/components/core/locales/ru-RU.ts b/packages/components/core/locales/ru-RU.ts index 0afad7980..f8f991510 100644 --- a/packages/components/core/locales/ru-RU.ts +++ b/packages/components/core/locales/ru-RU.ts @@ -101,6 +101,8 @@ export const ruRULocaleData = { customPeriodFrom: 'с', customPeriodTo: 'по', customPeriodErrorHint: 'Начало периода не может быть позже окончания', + customPeriodMinIntervalErrorHint: 'Период не может быть короче {{ value }}', + customPeriodMaxIntervalErrorHint: 'Период не может быть длиннее {{ value }}', backToPeriodSelection: 'Назад' } }, diff --git a/packages/components/core/locales/tk-TM.ts b/packages/components/core/locales/tk-TM.ts index 3c0cdad33..2444d3d76 100644 --- a/packages/components/core/locales/tk-TM.ts +++ b/packages/components/core/locales/tk-TM.ts @@ -101,6 +101,8 @@ export const tkTMLocaleData = { customPeriodFrom: 'başy', customPeriodTo: 'soňy', customPeriodErrorHint: 'Döwür gutarýan wagtyndan soň başlap bilmeýär', + customPeriodMinIntervalErrorHint: 'Döwür azyndan {{ value }} bolmaly', + customPeriodMaxIntervalErrorHint: 'Döwür köpi bilen {{ value }} bolmaly', backToPeriodSelection: 'Yzyna' } }, diff --git a/packages/components/filter-bar/filter-bar.en.md b/packages/components/filter-bar/filter-bar.en.md index 68a24896b..2f6d58670 100644 --- a/packages/components/filter-bar/filter-bar.en.md +++ b/packages/components/filter-bar/filter-bar.en.md @@ -45,6 +45,12 @@ For the `select` and `multiselect` types, the pipe template accepts an optional The `input` type is the only one without a popover: it renders a bare input that is typed into directly in the bar, using the pipe's `name` as its placeholder. The value is applied as you type — 200 ms after the last keystroke (`debounceTime`) and only from 3 characters on (`minLength`) — rather than through an Apply button. Text shorter than the threshold, including an emptied field, is applied as `null`, so the bar never keeps filtering by text that is no longer in the input. `Enter` and blur apply the value immediately and ignore the threshold: an explicit action is always applied as typed. Its only clear affordance is the built-in cleaner inside the input, so `removable` has no effect — an `input` pipe can only be removed programmatically. Values are trimmed, and a blank value is applied as `null`. Set its width with the `--kbq-filter-bar-pipe-input-width` CSS variable. +### Date and time limits + +The `date` and `datetime` pipe templates accept optional limits for the custom period. `minDateTime` and `maxDateTime` bound the selectable range — the `datetime` pipe uses the full instant (date and time), the `date` pipe only the day. `minInterval` and `maxInterval` constrain the length of the selected period (`end − start`) as a duration-like object (e.g. `{ days: 3 }`, `{ hours: 1 }`); a period outside these bounds shows an error and disables Apply, with the limit formatted for the current locale. All four are ignored by other pipe types. + + + ### Search in pipes If the filter has many values, it is useful to enable search in the drop-down menu. diff --git a/packages/components/filter-bar/filter-bar.ru.md b/packages/components/filter-bar/filter-bar.ru.md index d1526e92a..13f67a7c5 100644 --- a/packages/components/filter-bar/filter-bar.ru.md +++ b/packages/components/filter-bar/filter-bar.ru.md @@ -45,6 +45,12 @@ Тип `input` — единственный без поповера: он отображает обычное поле ввода, в котором печатают прямо в баре, а `name` пайпа используется как плейсхолдер. Значение применяется по ходу ввода — через 200 мс после последнего нажатия клавиши (`debounceTime`) и начиная с 3 символов (`minLength`), — а не через кнопку «Применить». Текст короче порога, в том числе очищенное поле, применяется как `null`, поэтому бар никогда не продолжает фильтровать по тексту, которого уже нет в поле. `Enter` и потеря фокуса применяют значение сразу и порог игнорируют: явное действие пользователя применяется как введено. Единственный способ очистки — встроенный клинер внутри поля, поэтому `removable` ни на что не влияет: пайп типа `input` можно удалить только программно. Значения обрезаются по краям, а пустое значение применяется как `null`. Ширина задаётся CSS-переменной `--kbq-filter-bar-pipe-input-width`. +### Ограничения даты и времени + +В шаблонах пайпов `date` и `datetime` доступны необязательные ограничения произвольного периода. `minDateTime` и `maxDateTime` задают границы выбираемого диапазона: пайп `datetime` использует полный момент (дату и время), пайп `date` — только день. `minInterval` и `maxInterval` ограничивают длину выбранного периода (`end − start`) и задаются объектом длительности (например, `{ days: 3 }`, `{ hours: 1 }`); период вне этих границ показывает ошибку и блокирует «Применить», а само ограничение форматируется под текущую локаль. Все четыре параметра игнорируются другими типами пайпов. + + + ### Поиск значений Если в фильтре много значений, то полезно включить поиск в выпадающем меню. diff --git a/packages/components/filter-bar/filter-bar.types.ts b/packages/components/filter-bar/filter-bar.types.ts index 965a88667..873465481 100644 --- a/packages/components/filter-bar/filter-bar.types.ts +++ b/packages/components/filter-bar/filter-bar.types.ts @@ -193,6 +193,36 @@ export interface KbqPipeTemplate extends Omit { * is used. Ignored by other pipe types. */ compareWith?: (o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined) => boolean; + /** + * Earliest selectable instant for the `date` / `datetime` pipe custom period. Accepts any value the + * configured `DateAdapter` can deserialize (with the default Luxon adapter: an ISO-8601 string, a + * `Date`, or a `DateTime`). The `date` pipe uses only its day (pinned to the start of day). Ignored by + * other pipe types. + */ + minDateTime?: unknown; + /** + * Latest selectable instant for the `date` / `datetime` pipe custom period. See + * {@link KbqPipeTemplate.minDateTime}. The `date` pipe uses only its day (pinned to the end of day). + * Ignored by other pipe types. + */ + maxDateTime?: unknown; + /** + * Minimum length of the `date` / `datetime` pipe custom period (`end − start`), as a Luxon + * duration-like object, e.g. `{ days: 1 }` or `{ hours: 2 }`. When the selected period is shorter, the + * editor shows an error and disables Apply. Ignored by other pipe types. + * + * Unlike {@link KbqPipeTemplate.minDateTime}, this value is NOT deserialized through the configured + * `DateAdapter` — it is applied directly via the date value's own `.plus()`, so this option currently + * assumes the default Luxon adapter and is not portable to other `DateAdapter` implementations (e.g. + * the Moment adapter). + */ + minInterval?: unknown; + /** + * Maximum length of the `date` / `datetime` pipe custom period (`end − start`). See + * {@link KbqPipeTemplate.minInterval}. When the selected period is longer, the editor shows an error and + * disables Apply. Ignored by other pipe types. + */ + maxInterval?: unknown; } export interface KbqSaveFilterError { diff --git a/packages/components/filter-bar/pipes/pipe-date-base.ts b/packages/components/filter-bar/pipes/pipe-date-base.ts index ba0012d89..560d1d103 100644 --- a/packages/components/filter-bar/pipes/pipe-date-base.ts +++ b/packages/components/filter-bar/pipes/pipe-date-base.ts @@ -1,12 +1,12 @@ import { AfterViewInit, Directive, inject, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormGroup } from '@angular/forms'; +import { FormControl, FormGroup, ValidatorFn } from '@angular/forms'; import { KbqButton, KbqButtonStyles } from '@koobiq/components/button'; import { DateAdapter, DateFormatter, ENTER, KbqComponentColors, PopUpPlacements } from '@koobiq/components/core'; import { KbqListSelection } from '@koobiq/components/list'; import { KbqPopoverTrigger } from '@koobiq/components/popover'; -import { KbqDateTimeValue } from '../filter-bar.types'; -import { KbqBasePipe } from './base-pipe'; +import { KbqDateTimeValue, KbqPipeTemplate } from '../filter-bar.types'; +import { getId, KbqBasePipe } from './base-pipe'; /** * Shared implementation for the `date` and `datetime` pipes. The two pipes differ only in how a range @@ -39,6 +39,38 @@ export abstract class KbqPipeDateBaseComponent extends KbqBasePipe`, `[min]`/`[max]` resolve to the DOM property (`string | number`), which a concrete `D` + // could not satisfy inside this generic component (matching the existing `any` date handling here). + // The runtime values are adapter-deserialized dates. + /** + * Lower bound instant applied to the calendars, date inputs and (datetime) timepickers. + * @docs-private + */ + protected min: any; + /** + * Upper bound instant applied to the calendars, date inputs and (datetime) timepickers. + * @docs-private + */ + protected max: any; + + /** Minimum length of the custom period (`end − start`), resolved from the template as a duration-like object. */ + private minInterval?: unknown; + /** Maximum length of the custom period (`end − start`), resolved from the template as a duration-like object. */ + private maxInterval?: unknown; + + /** Canonical duration units (largest-first) used to render an interval limit faithfully to its config. */ + private static readonly INTERVAL_UNITS = [ + 'years', + 'quarters', + 'months', + 'weeks', + 'days', + 'hours', + 'minutes', + 'seconds' + ] as const; + /** @docs-private */ readonly popover = viewChild.required('popover'); /** @docs-private */ @@ -48,6 +80,107 @@ export abstract class KbqPipeDateBaseComponent extends KbqBasePipe { + const template = templates?.find((item) => getId(item) === getId(this.data) && item.type === this.data.type); + + // Sticky, like the sibling KbqBasePipe.updateTemplates: an emission where this pipe's own + // template is momentarily absent must not blank out previously resolved bounds. + if (!template) return; + + const min = this.toValidDate(template.minDateTime); + const max = this.toValidDate(template.maxDateTime); + + // The date pipe has no clock; pin bounds to day edges so a time in the bound can't strand the value + // invalid. The datetime pipe keeps the full instant (single absolute window). `.startOf`/`.endOf` + // mirror the DateTime API the concrete subclasses already use (`adapter.today().startOf('day')`). + this.min = this.usesTime() ? min : min?.startOf('day'); + this.max = this.usesTime() ? max : max?.endOf('day'); + + this.minInterval = this.toValidInterval(template.minInterval); + this.maxInterval = this.toValidInterval(template.maxInterval); + // Re-validate an already open editor if the template (hence the interval limits) changes. + this.formGroup?.updateValueAndValidity(); + + this.changeDetectorRef.markForCheck(); + }; + + /** + * Deserializes a `minDateTime` / `maxDateTime` template value, treating an unset (`null`/`undefined`) + * value and an unparseable one alike as "no bound", so call sites need no own null-check. + */ + private toValidDate(value: unknown): any { + const date = this.adapter.deserialize(value); + + return date != null && this.adapter.isValid(date) ? date : undefined; + } + + /** + * Validates a `minInterval` / `maxInterval` template value, treating anything that isn't a genuine + * duration-like object — or that resolves to an invalid or sub-second span — as "no constraint" + * instead of letting it crash `validateInterval`/`formatInterval` downstream (both call `.plus()` on + * it directly). A bare number is rejected too: Luxon would silently read it as milliseconds, which + * would otherwise disable the constraint without any error (e.g. a `{ days: 5 }` typo'd as `5`). + */ + private toValidInterval(value: unknown): any { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return undefined; + + try { + const today = this.adapter.today(); + const probe = today.plus(value); + + if (!this.adapter.isValid(probe)) return undefined; + + // DurationUnit has no sub-second granularity, so a shorter interval can't be rendered by + // `durationLong` and would misleadingly show up as "0 seconds" — treat it as unset instead. + const { seconds = 0 } = this.adapter.durationObjectFromDates(today, probe, ['seconds'], true); + + return Math.abs(seconds) >= 1 ? value : undefined; + } catch { + return undefined; + } + } + + /** Clamps `value` into the configured `[min, max]` bounds using full-instant comparison. */ + private clampToBounds(value: D): D { + if (this.min && this.adapter.compareDateTime(value, this.min) < 0) return this.min; + if (this.max && this.adapter.compareDateTime(value, this.max) > 0) return this.max; + + return value; + } + + /** + * Cross-field validator flagging the custom period when its length (`end − start`) is outside the + * configured `[minInterval, maxInterval]`. Intervals are applied via `DateTime.plus`, so calendar units + * (months/years) are respected; unset intervals impose no constraint. + */ + private validateInterval: ValidatorFn = (group) => { + const start = (group as FormGroup).controls.start?.value; + const end = (group as FormGroup).controls.end?.value; + + if (!this.adapter.isDateInstance(start) || !this.adapter.isDateInstance(end)) return null; + + if (this.minInterval && this.adapter.compareDateTime(end, start.plus(this.minInterval)) < 0) { + return { kbqDateIntervalMin: true }; + } + + if (this.maxInterval && this.adapter.compareDateTime(end, start.plus(this.maxInterval)) > 0) { + return { kbqDateIntervalMax: true }; + } + + return null; + }; + + /** Formats a duration-like interval as a locale-aware human string via the formatter, e.g. "7 дней". */ + private formatInterval(interval: unknown): string { + const start = this.adapter.today(); + const units = KbqPipeDateBaseComponent.INTERVAL_UNITS.filter( + (unit) => (interval as any)?.[unit] != null || (interval as any)?.[unit.slice(0, -1)] != null + ); + + return this.formatter.durationLong(start, start.plus(interval), units); + } + /** formatted value for period */ get formattedValue(): string { if (this.start && this.end) { @@ -62,7 +195,7 @@ export abstract class KbqPipeDateBaseComponent extends KbqBasePipe extends KbqBasePipe extends KbqBasePipe 0) { + end = this.clampToBounds(start.plus(this.maxInterval)); } - return this.getDefaultEnd(); + return { start, end }; + } + + /** + * Upper bound for the START date input: the earlier of the END value and the configured `max`. + * @docs-private + */ + get startMax(): any { + const end = this.formGroup.controls.end.value; + + if (this.max && this.adapter.isDateInstance(end)) { + return this.adapter.compareDateTime(this.max, end) <= 0 ? this.max : end; + } + + return this.max ?? end; + } + + /** + * Whether the period is invalid specifically because it runs backwards (`start` later than `end`) — + * the one case `customPeriodErrorHint` actually describes. Deliberately compares the values rather + * than keying off the START control's `kbqDatepickerMax` error: `startMax` is `min(max, end)`, so that + * error fires both for a backwards period AND for a start beyond the configured `max`, and cannot + * tell them apart. Everything else that can invalidate a control here — a value outside + * `[min, max]` (`kbqDatepickerMin`/`kbqDatepickerMax`, `kbqTimepickerLowerThenMin`/ + * `kbqTimepickerHigherThenMax`) or unparseable typed text (`kbqDatepickerParse`/`kbqTimepickerParse`) + * — is left to the field's own error styling instead of being mislabelled as an ordering problem. + * @docs-private + */ + protected get isPeriodInverted(): boolean { + const start = this.formGroup.controls.start.value; + const end = this.formGroup.controls.end.value; + + return ( + this.adapter.isDateInstance(start) && + this.adapter.isDateInstance(end) && + this.adapter.compareDateTime(start, end) > 0 + ); + } + + /** + * Localized "period too short" hint with the `minInterval` limit interpolated and locale-formatted. + * @docs-private + */ + get minIntervalErrorHint(): string { + return this.localeData.datePipe.customPeriodMinIntervalErrorHint.replace( + '{{ value }}', + this.formatInterval(this.minInterval) + ); + } + + /** + * Localized "period too long" hint with the `maxInterval` limit interpolated and locale-formatted. + * @docs-private + */ + get maxIntervalErrorHint(): string { + return this.localeData.datePipe.customPeriodMaxIntervalErrorHint.replace( + '{{ value }}', + this.formatInterval(this.maxInterval) + ); } /** Whether the current pipe is empty. */ @@ -104,6 +316,15 @@ export abstract class KbqPipeDateBaseComponent extends KbqBasePipe extends KbqBasePipe extends KbqBasePipe @@ -76,6 +77,8 @@ @@ -85,21 +88,33 @@ - @if (formGroup.controls.start.invalid) { + @if (isPeriodInverted) { {{ localeData.datePipe.customPeriodErrorHint }} + } @else if (formGroup.errors?.kbqDateIntervalMin) { + + {{ minIntervalErrorHint }} + + } @else if (formGroup.errors?.kbqDateIntervalMax) { + + {{ maxIntervalErrorHint }} + } diff --git a/packages/components/filter-bar/pipes/pipe-date.spec.ts b/packages/components/filter-bar/pipes/pipe-date.spec.ts index 76d6b1289..c6407b165 100644 --- a/packages/components/filter-bar/pipes/pipe-date.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-date.spec.ts @@ -253,6 +253,30 @@ describe('KbqPipeDateComponent', () => { expect(adapter.sameDate(component.defaultStart, today.plus(PRESET_VALUES[0].start))).toBe(true); expect(adapter.sameDate(component.defaultEnd, today)).toBe(true); }); + + it('should clamp defaultEnd when the configured max is earlier than the raw default', () => { + fixture.componentInstance.pipeTemplates = [ + { + name: 'Date', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Date, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + maxDateTime: adapter.today().toISO() + } + ]; + fixture.componentInstance.activeFilter = createFilter([createPipe({ value: null })]); + fixture.detectChanges(); + + const component = getPipeComponent(); + const internal = asInternal(component); + + // raw default end is today + 1 day, which is after max (today, pinned to end of day) -> clamps to max + expect(adapter.sameDate(component.defaultStart, adapter.today())).toBe(true); + expect(adapter.compareDateTime(component.defaultEnd, internal.max)).toBe(0); + }); }); describe('showPeriod', () => { @@ -431,6 +455,52 @@ describe('KbqPipeDateComponent', () => { expect(component.disabled).toBe(true); }); + + it('should be true when end control is invalid', () => { + const component = getPipeComponent(); + const internal = asInternal(component); + + component.showPeriod(); + internal.formGroup.controls.end.setErrors({ invalid: true }); + + expect(component.disabled).toBe(true); + }); + }); + + describe('isPeriodInverted', () => { + beforeEach(() => { + setupSinglePipe({ value: null }); + }); + + it('should be true when start is later than end', () => { + const component = getPipeComponent(); + const internal = asInternal(component); + const today = adapter.today(); + + component.showPeriod(); + internal.formGroup.controls.start.setValue(today.plus({ days: 2 })); + internal.formGroup.controls.end.setValue(today); + + expect(internal.isPeriodInverted).toBe(true); + }); + + it('should be false for an out-of-bounds value, which still disables Apply', () => { + const component = getPipeComponent(); + const internal = asInternal(component); + const today = adapter.today(); + + component.showPeriod(); + internal.formGroup.controls.start.setValue(today); + internal.formGroup.controls.end.setValue(today.plus({ days: 1 })); + // what the datepicker's own max validator sets for a value past the configured `max` + internal.formGroup.controls.end.setErrors({ + kbqDatepickerMax: { max: today, actual: today.plus({ days: 1 }) } + }); + + // the period reads correctly (start before end), so the "start after end" hint must stay hidden + expect(internal.isPeriodInverted).toBe(false); + expect(component.disabled).toBe(true); + }); }); describe('date selection handlers', () => { @@ -589,4 +659,476 @@ describe('KbqPipeDateComponent', () => { expect(listText?.contains(icon)).toBe(true); })); }); + + describe('min / max bounds', () => { + const setupWithBounds = (bounds: Partial): KbqPipeDateComponent => { + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + fixture.componentInstance.pipeTemplates = [ + { + name: 'Date', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Date, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + ...bounds + } + ]; + fixture.componentInstance.activeFilter = createFilter([createPipe({ value: null })]); + fixture.detectChanges(); + + return getPipeComponent(); + }; + + it('should pin bounds to day edges (the date pipe has no clock)', () => { + const min = adapter.today().minus({ days: 5 }).set({ hour: 9, minute: 30, second: 15, millisecond: 500 }); + const max = adapter.today().plus({ days: 5 }).set({ hour: 18, minute: 45, second: 15, millisecond: 500 }); + + const internal = asInternal(setupWithBounds({ minDateTime: min.toISO(), maxDateTime: max.toISO() })); + + expect(adapter.sameDate(internal.min, min)).toBe(true); + expect([internal.min.hour, internal.min.minute, internal.min.second, internal.min.millisecond]).toEqual([ + 0, + 0, + 0, + 0 + ]); + + expect(adapter.sameDate(internal.max, max)).toBe(true); + expect([internal.max.hour, internal.max.minute, internal.max.second, internal.max.millisecond]).toEqual([ + 23, + 59, + 59, + 999 + ]); + }); + + it('should accept a DateTime object, not only an ISO string', () => { + const min = adapter.today().minus({ days: 2 }); + + const internal = asInternal(setupWithBounds({ minDateTime: min })); + + expect(adapter.sameDate(internal.min, min)).toBe(true); + }); + + it('should leave bounds undefined when not configured', () => { + const internal = asInternal(setupWithBounds({})); + + expect(internal.min).toBeUndefined(); + expect(internal.max).toBeUndefined(); + }); + + it('startMax should fall back to the end value when max is unset', () => { + const component = setupWithBounds({}); + + component.showPeriod(); + + const internal = asInternal(component); + + expect(internal.startMax).toBe(internal.formGroup.controls.end.value); + }); + + it('startMax should clamp to max, and end itself is already clamped there', () => { + const max = adapter.today().minus({ days: 1 }); + const component = setupWithBounds({ maxDateTime: max.toISO() }); + + component.showPeriod(); + + const internal = asInternal(component); + + // default end (today + 1 day) is now clamped to max by defaultEnd, so both land on max. + expect(adapter.sameDate(internal.formGroup.controls.end.value, max)).toBe(true); + expect(adapter.sameDate(internal.startMax, max)).toBe(true); + }); + + it('should update bounds when pipeTemplates change while the pipe is already constructed', () => { + const component = setupWithBounds({}); + + expect(asInternal(component).min).toBeUndefined(); + + const newMin = adapter.today().minus({ days: 10 }); + + fixture.componentInstance.pipeTemplates = [ + { + name: 'Date', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Date, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minDateTime: newMin.toISO() + } + ]; + fixture.detectChanges(); + + expect(adapter.sameDate(asInternal(component).min, newMin)).toBe(true); + }); + + it('should bind each pipe instance to its own template bounds', () => { + const OTHER_ID = 'OtherDate'; + const minA = adapter.today().minus({ days: 5 }); + const minB = adapter.today().minus({ days: 50 }); + + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + fixture.componentInstance.pipeTemplates = [ + { + name: 'Date', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Date, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minDateTime: minA.toISO() + }, + { + name: 'Other date', + id: OTHER_ID, + type: KbqPipeTypes.Date, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minDateTime: minB.toISO() + } + ]; + fixture.componentInstance.activeFilter = createFilter([ + createPipe({ value: null }), + createPipe({ id: OTHER_ID, value: null }) + ]); + fixture.detectChanges(); + + expect(adapter.sameDate(asInternal(getPipeComponent(0)).min, minA)).toBe(true); + expect(adapter.sameDate(asInternal(getPipeComponent(1)).min, minB)).toBe(true); + }); + + it('should treat an unparseable minDateTime/maxDateTime as no bound', () => { + const internal = asInternal( + setupWithBounds({ minDateTime: 'not-a-real-date', maxDateTime: 'also-not-a-real-date' }) + ); + + expect(internal.min).toBeUndefined(); + expect(internal.max).toBeUndefined(); + }); + + it('should not source bounds from a same-id template of a different pipe type', () => { + const internal = asInternal( + setupWithBounds({ + type: KbqPipeTypes.Datetime, + minDateTime: adapter.today().minus({ days: 1 }).toISO() + }) + ); + + expect(internal.min).toBeUndefined(); + }); + }); + + describe('min / max interval', () => { + const setupWithInterval = (template: Partial): KbqPipeDateComponent => { + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + fixture.componentInstance.pipeTemplates = [ + { + name: 'Date', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Date, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + ...template + } + ]; + fixture.componentInstance.activeFilter = createFilter([createPipe({ value: null })]); + fixture.detectChanges(); + + return getPipeComponent(); + }; + + const setRange = (component: KbqPipeDateComponent, start: DateTime, end: DateTime) => { + const internal = asInternal(component); + + component.showPeriod(); + internal.formGroup.controls.start.setValue(start); + internal.formGroup.controls.end.setValue(end); + }; + + it('should flag a period shorter than minInterval and disable Apply', () => { + const component = setupWithInterval({ minInterval: { days: 7 } }); + const today = adapter.today(); + + setRange(component, today, today.plus({ days: 3 })); + + expect(asInternal(component).formGroup.errors?.kbqDateIntervalMin).toBeTruthy(); + expect(component.disabled).toBe(true); + }); + + it('should flag a period longer than maxInterval and disable Apply', () => { + const component = setupWithInterval({ maxInterval: { days: 30 } }); + const today = adapter.today(); + + setRange(component, today, today.plus({ days: 45 })); + + expect(asInternal(component).formGroup.errors?.kbqDateIntervalMax).toBeTruthy(); + expect(component.disabled).toBe(true); + }); + + it('should accept a period within the interval bounds', () => { + const component = setupWithInterval({ minInterval: { days: 3 }, maxInterval: { days: 30 } }); + const today = adapter.today(); + + setRange(component, today, today.plus({ days: 10 })); + + expect(asInternal(component).formGroup.errors).toBeNull(); + expect(component.disabled).toBe(false); + }); + + it('should impose no interval constraint when unset', () => { + const component = setupWithInterval({}); + const today = adapter.today(); + + setRange(component, today, today.plus({ days: 1 })); + + expect(asInternal(component).formGroup.errors).toBeNull(); + expect(component.disabled).toBe(false); + }); + + it('should extend the default end to satisfy minInterval when the pipe has no stored value', () => { + const component = setupWithInterval({ minInterval: { days: 7 } }); + const today = adapter.today(); + + // Read both sides of the pair from the same showPeriod()-seeded FormGroup (a single + // computeDefaultRange() call), not from two independent defaultStart/defaultEnd getter reads, + // which each resolve their own `today()` and can differ by a stray millisecond under load. + component.showPeriod(); + const internal = asInternal(component); + const { start, end } = internal.formGroup.controls; + + expect(adapter.sameDate(start.value, today)).toBe(true); + expect(adapter.compareDateTime(end.value, start.value.plus({ days: 7 }))).toBe(0); + }); + + it('should shrink the default end to satisfy maxInterval when the pipe has no stored value', () => { + const component = setupWithInterval({ maxInterval: { hours: 12 } }); + + component.showPeriod(); + const { start, end } = asInternal(component).formGroup.controls; + + expect(adapter.compareDateTime(end.value, start.value.plus({ hours: 12 }))).toBe(0); + }); + + it('should pull the default start back when [min, max] alone cannot fit minInterval', () => { + const component = setupWithInterval({ + minDateTime: adapter.today().minus({ years: 1 }).toISO(), + maxDateTime: adapter.today().toISO(), + minInterval: { days: 7 } + }); + const internal = asInternal(component); + + component.showPeriod(); + const { start, end } = internal.formGroup.controls; + + // max is pinned to end-of-day for the date pipe; default end clamps there, and default start + // is pulled back (rather than left too close to end) so the span still satisfies minInterval. + expect(adapter.compareDateTime(end.value, internal.max)).toBe(0); + expect(adapter.compareDateTime(start.value, end.value.minus({ days: 7 }))).toBe(0); + }); + + it('should treat a malformed minInterval/maxInterval object as no constraint instead of crashing', () => { + const component = setupWithInterval({ minInterval: { days: true } as any, maxInterval: 'P1D' as any }); + const internal = asInternal(component); + + expect(internal.minInterval).toBeUndefined(); + expect(internal.maxInterval).toBeUndefined(); + expect(() => component.defaultEnd).not.toThrow(); + }); + + it('should treat a bare number as no constraint, not silently as milliseconds', () => { + const component = setupWithInterval({ minInterval: 5 as any }); + + expect(asInternal(component).minInterval).toBeUndefined(); + }); + + it('should re-validate an already-open editor when minInterval changes live', () => { + const component = setupWithInterval({}); + const internal = asInternal(component); + + component.showPeriod(); + + const today = adapter.today(); + + internal.formGroup.controls.start.setValue(today); + internal.formGroup.controls.end.setValue(today.plus({ days: 3 })); + + expect(internal.formGroup.errors).toBeNull(); + + fixture.componentInstance.pipeTemplates = [ + { + name: 'Date', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Date, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minInterval: { days: 7 } + } + ]; + fixture.detectChanges(); + + expect(internal.formGroup.errors?.kbqDateIntervalMin).toBeTruthy(); + }); + + it('should interpolate the locale-formatted minInterval into the hint, passing the matching units', () => { + const component = setupWithInterval({ minInterval: { days: 7 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + const hint = component.minIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['days']); + expect(hint).toContain('SENTINEL'); + expect(hint).not.toContain('{{ value }}'); + }); + + it('should interpolate the locale-formatted maxInterval into the hint, passing the matching units', () => { + const component = setupWithInterval({ maxInterval: { days: 30 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + const hint = component.maxIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['days']); + expect(hint).toContain('SENTINEL'); + expect(hint).not.toContain('{{ value }}'); + }); + + it('should pass the "months" unit for a { months: N } interval, as used by the docs example', () => { + const component = setupWithInterval({ maxInterval: { months: 3 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + void component.maxIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['months']); + }); + + it('should pass the "weeks" unit for a { weeks: N } interval', () => { + const component = setupWithInterval({ minInterval: { weeks: 2 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + void component.minIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['weeks']); + }); + + it('should pass all matching units for a multi-unit interval', () => { + const component = setupWithInterval({ minInterval: { days: 1, hours: 12 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + void component.minIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['days', 'hours']); + }); + }); + + describe('interval hint rendering', () => { + it('should render the interpolated minInterval hint text in the popover', fakeAsync(() => { + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + fixture.componentInstance.pipeTemplates = [ + { + name: 'Date', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Date, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minInterval: { days: 7 } + } + ]; + fixture.componentInstance.activeFilter = createFilter([createPipe({ value: null })]); + fixture.detectChanges(); + + const component = getPipeComponent(); + const overlayContainer = TestBed.inject(OverlayContainer).getContainerElement(); + + component.open(); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + const internal = asInternal(component); + + // showPeriod()'s deferred focus-restore isn't relevant to hint rendering; stub it out so the + // real (isListMode-driven) template swap below isn't gated on the returnButton view query. + internal.returnButton = () => ({ focusViaKeyboard: () => {} }); + + component.showPeriod(); + fixture.detectChanges(); + flush(); + + const today = adapter.today(); + + internal.formGroup.controls.start.setValue(today); + internal.formGroup.controls.end.setValue(today.plus({ days: 2 })); + fixture.detectChanges(); + + const hint = overlayContainer.querySelector('.kbq-date-period__hint'); + + expect(hint?.textContent?.trim()).toBe(component.minIntervalErrorHint); + })); + + it('should not show the "start after end" hint for a value that is merely out of bounds', fakeAsync(() => { + const maxDateTime = adapter.today(); + + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + fixture.componentInstance.pipeTemplates = [ + { + name: 'Date', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Date, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + maxDateTime: maxDateTime.toISO() + } + ]; + fixture.componentInstance.activeFilter = createFilter([createPipe({ value: null })]); + fixture.detectChanges(); + + const component = getPipeComponent(); + const overlayContainer = TestBed.inject(OverlayContainer).getContainerElement(); + + component.open(); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + const internal = asInternal(component); + + internal.returnButton = () => ({ focusViaKeyboard: () => {} }); + + component.showPeriod(); + fixture.detectChanges(); + flush(); + + // a correctly ordered period whose end is pushed past the configured `max` — the real + // datepicker max validator flags the control, but it is NOT an ordering problem + const start = maxDateTime.startOf('day'); + + internal.formGroup.controls.start.setValue(start); + internal.formGroup.controls.end.setValue(maxDateTime.plus({ days: 5 })); + fixture.detectChanges(); + + expect(internal.formGroup.controls.end.invalid).toBe(true); + expect(internal.isPeriodInverted).toBe(false); + expect(overlayContainer.querySelector('.kbq-date-period__hint')).toBeNull(); + expect(component.disabled).toBe(true); + })); + }); }); diff --git a/packages/components/filter-bar/pipes/pipe-date.ts b/packages/components/filter-bar/pipes/pipe-date.ts index d89fc5411..9f325d9f7 100644 --- a/packages/components/filter-bar/pipes/pipe-date.ts +++ b/packages/components/filter-bar/pipes/pipe-date.ts @@ -45,6 +45,10 @@ import { KbqPipeState } from './pipe-state'; encapsulation: ViewEncapsulation.None }) export class KbqPipeDateComponent extends KbqPipeDateBaseComponent { + protected usesTime(): boolean { + return false; + } + protected formatRange(start: D, end: D): string { return this.formatter.rangeShortDate(start, end); } diff --git a/packages/components/filter-bar/pipes/pipe-datetime.html b/packages/components/filter-bar/pipes/pipe-datetime.html index 24facf0bc..3ea452f8b 100644 --- a/packages/components/filter-bar/pipes/pipe-datetime.html +++ b/packages/components/filter-bar/pipes/pipe-datetime.html @@ -67,7 +67,8 @@ @@ -76,13 +77,21 @@ - + @@ -93,6 +102,8 @@ @@ -101,19 +112,35 @@ - + - @if (formGroup.controls.start.invalid) { + @if (isPeriodInverted) { {{ localeData.datePipe.customPeriodErrorHint }} + } @else if (formGroup.errors?.kbqDateIntervalMin) { + + {{ minIntervalErrorHint }} + + } @else if (formGroup.errors?.kbqDateIntervalMax) { + + {{ maxIntervalErrorHint }} + } diff --git a/packages/components/filter-bar/pipes/pipe-datetime.spec.ts b/packages/components/filter-bar/pipes/pipe-datetime.spec.ts index 23e9793b0..7afd559cd 100644 --- a/packages/components/filter-bar/pipes/pipe-datetime.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-datetime.spec.ts @@ -253,6 +253,32 @@ describe('KbqPipeDatetimeComponent', () => { expect(adapter.sameDate(component.defaultStart, today.plus(PRESET_VALUES[0].start as any))).toBe(true); expect(adapter.sameDate(component.defaultEnd, today)).toBe(true); }); + + it('should clamp defaultEnd when the configured max is earlier than the raw default', () => { + const max = adapter.today(); + + fixture.componentInstance.pipeTemplates = [ + { + name: 'Datetime', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Datetime, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + maxDateTime: max.toISO() + } + ]; + fixture.componentInstance.activeFilter = createFilter([createPipe({ value: null })]); + fixture.detectChanges(); + + const component = getPipeComponent(); + const internal = asInternal(component); + + // raw default end is end of today, which is after max ('now') -> clamps down to max + expect(component.defaultStart.equals(adapter.today().startOf('day'))).toBe(true); + expect(adapter.compareDateTime(component.defaultEnd, internal.max)).toBe(0); + }); }); describe('showPeriod', () => { @@ -431,6 +457,70 @@ describe('KbqPipeDatetimeComponent', () => { expect(component.disabled).toBe(true); }); + + it('should be true when end control is invalid', () => { + const component = getPipeComponent(); + const internal = asInternal(component); + + component.showPeriod(); + internal.formGroup.controls.end.setErrors({ invalid: true }); + + expect(component.disabled).toBe(true); + }); + }); + + describe('isPeriodInverted', () => { + beforeEach(() => { + setupSinglePipe({ value: null }); + }); + + it('should be true when start is later than end', () => { + const component = getPipeComponent(); + const internal = asInternal(component); + const start = adapter.today().set({ hour: 18, minute: 0, second: 0, millisecond: 0 }); + + component.showPeriod(); + internal.formGroup.controls.start.setValue(start); + internal.formGroup.controls.end.setValue(start.minus({ hours: 3 })); + + expect(internal.isPeriodInverted).toBe(true); + }); + + it('should be true for a same-day period inverted only by its time part', () => { + const component = getPipeComponent(); + const internal = asInternal(component); + const day = adapter.today().startOf('day'); + + component.showPeriod(); + internal.formGroup.controls.start.setValue(day.set({ hour: 18 })); + internal.formGroup.controls.end.setValue(day.set({ hour: 9 })); + + expect(internal.isPeriodInverted).toBe(true); + }); + + it('should be false for out-of-bounds date/time values, which still disable Apply', () => { + const component = getPipeComponent(); + const internal = asInternal(component); + const start = adapter.today().set({ hour: 9, minute: 0, second: 0, millisecond: 0 }); + const end = start.plus({ hours: 3 }); + + component.showPeriod(); + internal.formGroup.controls.start.setValue(start); + internal.formGroup.controls.end.setValue(end); + + // what the datepicker / timepicker validators set for values outside the configured bounds + for (const errors of [ + { kbqDatepickerMax: { max: start, actual: end } }, + { kbqTimepickerHigherThenMax: { max: start, actual: end } }, + { kbqTimepickerLowerThenMin: { min: end, actual: start } } + ]) { + internal.formGroup.controls.end.setErrors(errors); + + // the period reads correctly (start before end), so the "start after end" hint stays hidden + expect(internal.isPeriodInverted).toBe(false); + expect(component.disabled).toBe(true); + } + }); }); describe('date selection handlers', () => { @@ -600,4 +690,412 @@ describe('KbqPipeDatetimeComponent', () => { expect(listText?.contains(icon)).toBe(true); })); }); + + describe('min / max bounds', () => { + const setupWithBounds = (bounds: Partial): KbqPipeDatetimeComponent => { + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + fixture.componentInstance.pipeTemplates = [ + { + name: 'Datetime', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Datetime, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + ...bounds + } + ]; + fixture.componentInstance.activeFilter = createFilter([createPipe({ value: null })]); + fixture.detectChanges(); + + return getPipeComponent(); + }; + + it('should keep the full instant (single absolute window)', () => { + const min = adapter.today().minus({ days: 5 }).set({ hour: 9, minute: 30, second: 15, millisecond: 500 }); + const max = adapter.today().plus({ days: 5 }).set({ hour: 18, minute: 45, second: 15, millisecond: 500 }); + + const internal = asInternal(setupWithBounds({ minDateTime: min.toISO(), maxDateTime: max.toISO() })); + + expect(adapter.compareDateTime(internal.min, min)).toBe(0); + expect(adapter.compareDateTime(internal.max, max)).toBe(0); + }); + + it('should accept a DateTime object, not only an ISO string', () => { + const min = adapter.today().minus({ days: 2 }).set({ hour: 8, minute: 0, second: 0, millisecond: 0 }); + + const internal = asInternal(setupWithBounds({ minDateTime: min })); + + expect(adapter.compareDateTime(internal.min, min)).toBe(0); + }); + + it('should leave bounds undefined when not configured', () => { + const internal = asInternal(setupWithBounds({})); + + expect(internal.min).toBeUndefined(); + expect(internal.max).toBeUndefined(); + }); + + it('startMax should fall back to the end value when max is unset', () => { + const component = setupWithBounds({}); + + component.showPeriod(); + + const internal = asInternal(component); + + expect(internal.startMax).toBe(internal.formGroup.controls.end.value); + }); + + it('startMax should clamp to max, and end itself is already clamped there', () => { + const max = adapter.today().minus({ days: 1 }).set({ hour: 12, minute: 0, second: 0, millisecond: 0 }); + const component = setupWithBounds({ maxDateTime: max.toISO() }); + + component.showPeriod(); + + const internal = asInternal(component); + + // default end (today end-of-day) is now clamped to max by defaultEnd, so both land on max. + expect(adapter.compareDateTime(internal.formGroup.controls.end.value, max)).toBe(0); + expect(adapter.compareDateTime(internal.startMax, max)).toBe(0); + }); + + it('should update bounds when pipeTemplates change while the pipe is already constructed', () => { + const component = setupWithBounds({}); + + expect(asInternal(component).min).toBeUndefined(); + + const newMin = adapter.today().minus({ days: 10 }); + + fixture.componentInstance.pipeTemplates = [ + { + name: 'Datetime', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Datetime, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minDateTime: newMin.toISO() + } + ]; + fixture.detectChanges(); + + expect(adapter.compareDateTime(asInternal(component).min, newMin)).toBe(0); + }); + + it('should bind each pipe instance to its own template bounds', () => { + const OTHER_ID = 'OtherDatetime'; + const minA = adapter.today().minus({ days: 5 }); + const minB = adapter.today().minus({ days: 50 }); + + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + fixture.componentInstance.pipeTemplates = [ + { + name: 'Datetime', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Datetime, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minDateTime: minA.toISO() + }, + { + name: 'Other datetime', + id: OTHER_ID, + type: KbqPipeTypes.Datetime, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minDateTime: minB.toISO() + } + ]; + fixture.componentInstance.activeFilter = createFilter([ + createPipe({ value: null }), + createPipe({ id: OTHER_ID, value: null }) + ]); + fixture.detectChanges(); + + expect(adapter.compareDateTime(asInternal(getPipeComponent(0)).min, minA)).toBe(0); + expect(adapter.compareDateTime(asInternal(getPipeComponent(1)).min, minB)).toBe(0); + }); + + it('should treat an unparseable minDateTime/maxDateTime as no bound', () => { + const internal = asInternal( + setupWithBounds({ minDateTime: 'not-a-real-date', maxDateTime: 'also-not-a-real-date' }) + ); + + expect(internal.min).toBeUndefined(); + expect(internal.max).toBeUndefined(); + }); + + it('should not source bounds from a same-id template of a different pipe type', () => { + const internal = asInternal( + setupWithBounds({ + type: KbqPipeTypes.Date, + minDateTime: adapter.today().minus({ days: 1 }).toISO() + }) + ); + + expect(internal.min).toBeUndefined(); + }); + }); + + describe('min / max interval', () => { + const setupWithInterval = (template: Partial): KbqPipeDatetimeComponent => { + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + fixture.componentInstance.pipeTemplates = [ + { + name: 'Datetime', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Datetime, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + ...template + } + ]; + fixture.componentInstance.activeFilter = createFilter([createPipe({ value: null })]); + fixture.detectChanges(); + + return getPipeComponent(); + }; + + const setRange = (component: KbqPipeDatetimeComponent, start: DateTime, end: DateTime) => { + const internal = asInternal(component); + + component.showPeriod(); + internal.formGroup.controls.start.setValue(start); + internal.formGroup.controls.end.setValue(end); + }; + + it('should flag a period shorter than minInterval (sub-day) and disable Apply', () => { + const component = setupWithInterval({ minInterval: { hours: 2 } }); + const start = adapter.today().set({ hour: 9, minute: 0, second: 0, millisecond: 0 }); + + setRange(component, start, start.plus({ hours: 1 })); + + expect(asInternal(component).formGroup.errors?.kbqDateIntervalMin).toBeTruthy(); + expect(component.disabled).toBe(true); + }); + + it('should flag a period longer than maxInterval (sub-day) and disable Apply', () => { + const component = setupWithInterval({ maxInterval: { hours: 12 } }); + const start = adapter.today().set({ hour: 9, minute: 0, second: 0, millisecond: 0 }); + + setRange(component, start, start.plus({ hours: 13 })); + + expect(asInternal(component).formGroup.errors?.kbqDateIntervalMax).toBeTruthy(); + expect(component.disabled).toBe(true); + }); + + it('should accept a period within the interval bounds', () => { + const component = setupWithInterval({ minInterval: { hours: 1 }, maxInterval: { days: 7 } }); + const start = adapter.today().set({ hour: 9, minute: 0, second: 0, millisecond: 0 }); + + setRange(component, start, start.plus({ hours: 5 })); + + expect(asInternal(component).formGroup.errors).toBeNull(); + expect(component.disabled).toBe(false); + }); + + it('should impose no interval constraint when unset', () => { + const component = setupWithInterval({}); + const start = adapter.today().set({ hour: 9, minute: 0, second: 0, millisecond: 0 }); + + setRange(component, start, start.plus({ hours: 1 })); + + expect(asInternal(component).formGroup.errors).toBeNull(); + expect(component.disabled).toBe(false); + }); + + it('should extend the default end to satisfy minInterval when the pipe has no stored value', () => { + const component = setupWithInterval({ minInterval: { days: 2 } }); + + // Read both sides of the pair from the same showPeriod()-seeded FormGroup (a single + // computeDefaultRange() call), not from two independent defaultStart/defaultEnd getter reads, + // which each resolve their own `today()` and can differ by a stray millisecond under load. + component.showPeriod(); + const { start, end } = asInternal(component).formGroup.controls; + + expect(adapter.compareDateTime(end.value, start.value.plus({ days: 2 }))).toBe(0); + }); + + it('should shrink the default end to satisfy maxInterval when the pipe has no stored value', () => { + const component = setupWithInterval({ maxInterval: { hours: 12 } }); + + component.showPeriod(); + const { start, end } = asInternal(component).formGroup.controls; + + expect(adapter.compareDateTime(end.value, start.value.plus({ hours: 12 }))).toBe(0); + }); + + it('should pull the default start back when [min, max] alone cannot fit minInterval', () => { + // Fixed offset from midnight (not wall-clock "now") so the scenario is deterministic: this + // reproduces the shipped docs example (minDateTime/maxDateTime + minInterval on the same + // template), where `max` can land soon after local midnight. + const maxDateTime = adapter.today().startOf('day').plus({ minutes: 30 }); + const component = setupWithInterval({ + minDateTime: adapter.today().minus({ days: 7 }).toISO(), + maxDateTime: maxDateTime.toISO(), + minInterval: { hours: 1 } + }); + const internal = asInternal(component); + + component.showPeriod(); + const { start, end } = internal.formGroup.controls; + + expect(adapter.compareDateTime(end.value, internal.max)).toBe(0); + expect(adapter.compareDateTime(start.value, end.value.minus({ hours: 1 }))).toBe(0); + }); + + it('should treat a malformed minInterval/maxInterval object as no constraint instead of crashing', () => { + const component = setupWithInterval({ minInterval: { days: true } as any, maxInterval: 'P1D' as any }); + const internal = asInternal(component); + + expect(internal.minInterval).toBeUndefined(); + expect(internal.maxInterval).toBeUndefined(); + expect(() => component.defaultEnd).not.toThrow(); + }); + + it('should treat a bare number as no constraint, not silently as milliseconds', () => { + const component = setupWithInterval({ minInterval: 5 as any }); + + expect(asInternal(component).minInterval).toBeUndefined(); + }); + + it('should re-validate an already-open editor when minInterval changes live', () => { + const component = setupWithInterval({}); + const internal = asInternal(component); + + component.showPeriod(); + + const start = adapter.today().set({ hour: 9, minute: 0, second: 0, millisecond: 0 }); + + internal.formGroup.controls.start.setValue(start); + internal.formGroup.controls.end.setValue(start.plus({ hours: 3 })); + + expect(internal.formGroup.errors).toBeNull(); + + fixture.componentInstance.pipeTemplates = [ + { + name: 'Datetime', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Datetime, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minInterval: { hours: 5 } + } + ]; + fixture.detectChanges(); + + expect(internal.formGroup.errors?.kbqDateIntervalMin).toBeTruthy(); + }); + + it('should interpolate the locale-formatted minInterval into the hint, passing the matching units', () => { + const component = setupWithInterval({ minInterval: { hours: 2 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + const hint = component.minIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['hours']); + expect(hint).toContain('SENTINEL'); + expect(hint).not.toContain('{{ value }}'); + }); + + it('should interpolate the locale-formatted maxInterval into the hint, passing the matching units', () => { + const component = setupWithInterval({ maxInterval: { days: 7 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + const hint = component.maxIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['days']); + expect(hint).toContain('SENTINEL'); + expect(hint).not.toContain('{{ value }}'); + }); + + it('should pass the "months" unit for a { months: N } interval, as used by the docs example', () => { + const component = setupWithInterval({ maxInterval: { months: 3 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + void component.maxIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['months']); + }); + + it('should pass the "weeks" unit for a { weeks: N } interval', () => { + const component = setupWithInterval({ minInterval: { weeks: 2 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + void component.minIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['weeks']); + }); + + it('should pass all matching units for a multi-unit interval', () => { + const component = setupWithInterval({ minInterval: { days: 1, hours: 12 } }); + const spy = jest.spyOn(asInternal(component).formatter, 'durationLong').mockReturnValue('SENTINEL'); + + void component.minIntervalErrorHint; + + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), ['days', 'hours']); + }); + }); + + describe('interval hint rendering', () => { + it('should render the interpolated minInterval hint text in the popover', fakeAsync(() => { + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + fixture.componentInstance.pipeTemplates = [ + { + name: 'Datetime', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Datetime, + values: PRESET_VALUES, + cleanable: false, + removable: false, + disabled: false, + minInterval: { hours: 5 } + } + ]; + fixture.componentInstance.activeFilter = createFilter([createPipe({ value: null })]); + fixture.detectChanges(); + + const component = getPipeComponent(); + const overlayContainer = TestBed.inject(OverlayContainer).getContainerElement(); + + component.open(); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + const internal = asInternal(component); + + // showPeriod()'s deferred focus-restore isn't relevant to hint rendering; stub it out so the + // real (isListMode-driven) template swap below isn't gated on the returnButton view query. + internal.returnButton = () => ({ focusViaKeyboard: () => {} }); + + component.showPeriod(); + fixture.detectChanges(); + flush(); + + const start = adapter.today().set({ hour: 9, minute: 0, second: 0, millisecond: 0 }); + + internal.formGroup.controls.start.setValue(start); + internal.formGroup.controls.end.setValue(start.plus({ hours: 2 })); + fixture.detectChanges(); + + const hint = overlayContainer.querySelector('.kbq-date-period__hint'); + + expect(hint?.textContent?.trim()).toBe(component.minIntervalErrorHint); + })); + }); }); diff --git a/packages/components/filter-bar/pipes/pipe-datetime.ts b/packages/components/filter-bar/pipes/pipe-datetime.ts index 4bffeb9e7..18754fce4 100644 --- a/packages/components/filter-bar/pipes/pipe-datetime.ts +++ b/packages/components/filter-bar/pipes/pipe-datetime.ts @@ -45,6 +45,10 @@ import { KbqPipeState } from './pipe-state'; encapsulation: ViewEncapsulation.None }) export class KbqPipeDatetimeComponent extends KbqPipeDateBaseComponent { + protected usesTime(): boolean { + return true; + } + protected formatRange(start: D, end: D): string { return this.formatter.rangeShortDateTime(start, end); } diff --git a/packages/docs-examples/components/filter-bar/filter-bar-date-limits/filter-bar-date-limits-example.ts b/packages/docs-examples/components/filter-bar/filter-bar-date-limits/filter-bar-date-limits-example.ts new file mode 100644 index 000000000..8ea2e301e --- /dev/null +++ b/packages/docs-examples/components/filter-bar/filter-bar-date-limits/filter-bar-date-limits-example.ts @@ -0,0 +1,73 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; +import { KbqFilterBarModule, KbqPipe, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar'; +import { DateTime } from 'luxon'; + +/** + * @title filter-bar-date-limits + */ +@Component({ + selector: 'filter-bar-date-limits-example', + imports: [ + KbqFilterBarModule, + LuxonDateModule + ], + template: ` + + @for (pipe of pipes; track pipe) { + + } + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FilterBarDateLimitsExample { + pipes: KbqPipe[] = [ + { + name: 'Date', + type: KbqPipeTypes.Date, + value: null, + + cleanable: true, + removable: false, + disabled: false + }, + { + name: 'Datetime', + type: KbqPipeTypes.Datetime, + value: null, + + cleanable: true, + removable: false, + disabled: false + } + ]; + + pipeTemplates: KbqPipeTemplate[] = [ + { + name: 'Date', + type: KbqPipeTypes.Date, + // Selectable window: from a year ago up to today (only the day is used by the `date` pipe). + minDateTime: DateTime.now().minus({ years: 1 }), + maxDateTime: DateTime.now(), + // The selected period must not be longer than 3 months. + maxInterval: { months: 3 }, + cleanable: true, + removable: false, + disabled: false + }, + { + name: 'Datetime', + type: KbqPipeTypes.Datetime, + // Selectable window: the last 7 days up to now (date and time). + minDateTime: DateTime.now().minus({ days: 7 }), + maxDateTime: DateTime.now(), + // The selected period must be between 1 hour and 3 days long. + minInterval: { hours: 1 }, + maxInterval: { days: 3 }, + cleanable: true, + removable: false, + disabled: false + } + ]; +} diff --git a/packages/docs-examples/components/filter-bar/index.ts b/packages/docs-examples/components/filter-bar/index.ts index 6043764db..711f2366d 100644 --- a/packages/docs-examples/components/filter-bar/index.ts +++ b/packages/docs-examples/components/filter-bar/index.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { FilterBarCleanableExample } from './filter-bar-cleanable/filter-bar-cleanable-example'; import { FilterBarCompleteFunctionsExample } from './filter-bar-complete-functions/filter-bar-complete-functions-example'; import { FilterBarCustomPipeExample } from './filter-bar-custom-pipe/filter-bar-custom-pipe-example'; +import { FilterBarDateLimitsExample } from './filter-bar-date-limits/filter-bar-date-limits-example'; import { FilterBarInactiveFilterExample } from './filter-bar-inactive-filter/filter-bar-inactive-filter-example'; import { FilterBarLocalizationExample } from './filter-bar-localization/filter-bar-localization-example'; import { FilterBarMasterCheckboxExample } from './filter-bar-master-checkbox/filter-bar-master-checkbox-example'; @@ -20,6 +21,7 @@ export { FilterBarCleanableExample, FilterBarCompleteFunctionsExample, FilterBarCustomPipeExample, + FilterBarDateLimitsExample, FilterBarInactiveFilterExample, FilterBarLocalizationExample, FilterBarMasterCheckboxExample, @@ -38,6 +40,7 @@ export { const EXAMPLES = [ FilterBarOverviewExample, FilterBarPipeTypesExample, + FilterBarDateLimitsExample, FilterBarRemovableExample, FilterBarCleanableExample, FilterBarRequiredExample, diff --git a/packages/docs-examples/example-module.ts b/packages/docs-examples/example-module.ts index 27a90c1ae..e7acd30fb 100644 --- a/packages/docs-examples/example-module.ts +++ b/packages/docs-examples/example-module.ts @@ -2085,6 +2085,18 @@ export const EXAMPLE_COMPONENTS: {[id: string]: LiveExample} = { "primaryFile": "filter-bar-custom-pipe-example.ts", "importPath": "components/filter-bar" }, + "filter-bar-date-limits": { + "packagePath": "components/filter-bar/filter-bar-date-limits", + "title": "filter-bar-date-limits", + "componentName": "FilterBarDateLimitsExample", + "files": [ + "filter-bar-date-limits-example.ts" + ], + "selector": "filter-bar-date-limits-example", + "additionalComponents": [], + "primaryFile": "filter-bar-date-limits-example.ts", + "importPath": "components/filter-bar" + }, "filter-bar-inactive-filter": { "packagePath": "components/filter-bar/filter-bar-inactive-filter", "title": "filter-bar-readonly-pipe", @@ -7054,6 +7066,8 @@ return import('@koobiq/docs-examples/components/filter-bar'); case 'filter-bar-complete-functions': return import('@koobiq/docs-examples/components/filter-bar'); case 'filter-bar-custom-pipe': +return import('@koobiq/docs-examples/components/filter-bar'); + case 'filter-bar-date-limits': return import('@koobiq/docs-examples/components/filter-bar'); case 'filter-bar-inactive-filter': return import('@koobiq/docs-examples/components/filter-bar'); diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 6d32f5ba0..ccce91b0e 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -474,6 +474,8 @@ export const enUSLocaleData: { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -721,6 +723,8 @@ export const esLALocaleData: { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -1191,6 +1195,8 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -1413,6 +1419,8 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -1631,6 +1639,8 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -1855,6 +1865,8 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -2075,6 +2087,8 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -4022,6 +4036,8 @@ export const ptBRLocaleData: { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -4456,6 +4472,8 @@ export const ruRULocaleData: { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -4811,6 +4829,8 @@ export const tkTMLocaleData: { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index 1aafb84c4..223256c96 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -99,6 +99,8 @@ export const KBQ_FILTER_BAR_CONFIGURATION: InjectionToken<{ customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }>; @@ -150,6 +152,8 @@ export const KBQ_FILTER_BAR_DEFAULT_CONFIGURATION: { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -280,6 +284,8 @@ export class KbqFilterBar implements KbqFilterBarHost { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; } | null; @@ -408,6 +414,8 @@ class KbqFilterRefresher { customPeriodFrom: string; customPeriodTo: string; customPeriodErrorHint: string; + customPeriodMinIntervalErrorHint: string; + customPeriodMaxIntervalErrorHint: string; backToPeriodSelection: string; }; }; @@ -668,7 +676,12 @@ export abstract class KbqPipeDateBaseComponent extends KbqBasePipe; + protected max: any; + get maxIntervalErrorHint(): string; + protected min: any; + get minIntervalErrorHint(): string; // (undocumented) ngAfterViewInit(): void; // (undocumented) @@ -695,7 +708,9 @@ export abstract class KbqPipeDateBaseComponent extends KbqBasePipe, never, never, {}, {}, never, never, true, never>; // (undocumented) @@ -711,6 +726,8 @@ export class KbqPipeDateComponent extends KbqPipeDateBaseComponent { // (undocumented) protected getDefaultStart(): D; // (undocumented) + protected usesTime(): boolean; + // (undocumented) static ɵcmp: _angular_core.ɵɵComponentDeclaration, "kbq-pipe-date", never, {}, {}, never, never, true, never>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; @@ -725,6 +742,8 @@ export class KbqPipeDatetimeComponent extends KbqPipeDateBaseComponent { // (undocumented) protected getDefaultStart(): D; // (undocumented) + protected usesTime(): boolean; + // (undocumented) static ɵcmp: _angular_core.ɵɵComponentDeclaration, "kbq-pipe-datetime", never, {}, {}, never, never, true, never>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; @@ -886,6 +905,10 @@ export class KbqPipeState { // @public (undocumented) export interface KbqPipeTemplate extends Omit { compareWith?: (o1: KbqSelectValue | null | undefined, o2: KbqSelectValue | null | undefined) => boolean; + maxDateTime?: unknown; + maxInterval?: unknown; + minDateTime?: unknown; + minInterval?: unknown; // (undocumented) values?: unknown[]; // (undocumented)