From 9d05cd5a18dfc9a247fe145692b69f19a21074b1 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 4 Aug 2026 17:59:28 +0300 Subject: [PATCH 1/3] feat(core): add duration date pipes (#DS-4652) Adds `durationShortest`, `durationLong` and `durationShort` in all three existing flavours -- pure, `...ImpurePipe` and locale-aware `kbq*` -- so the duration formats are usable from a template instead of only through `DateFormatter`. They take a `[from, to]` tuple, like the range pipes. Two behaviour changes to the existing pipes come with it: - The range pipes no longer throw when neither bound is a date. `[null, null]` reached `openedRangeDate(null, null)`, which throws and aborts the rendering of the whole view -- reachable as soon as a range form control is left empty. They now render an empty string, and so do the duration pipes for a missing, unparseable or reversed range. `DateAdapter.deserialize()` returns a truthy *invalid* date for an unparseable value, so the previous `date ? ... : ''` guards never caught malformed input; an invalid bound is now treated as an absent one. - `BaseLocaleAwareFormatterPipe` compares array inputs element-wise. A `[from, to]` tuple rebuilt on every change detection cycle -- from a getter or a `computed()` -- missed the cache every tick, which is the most expensive case for durations. Also documents the three pipe families in date-formatter.{en,ru}.md, where the 13 `kbq*` pipes were undocumented, and adds a duration section and a locale toggle to the date-pipes dev app. --- packages/components-dev/date-pipes/module.ts | 45 +- .../components-dev/date-pipes/template.html | 131 ++++++ .../core/formatters/date/date-formatter.en.md | 85 +++- .../core/formatters/date/date-formatter.ru.md | 78 +++- .../core/formatters/date/formatter.pipe.ts | 352 +++++++++++--- .../core/formatters/date/formatter.spec.ts | 441 +++++++++++++++++- packages/components/core/formatters/index.ts | 27 ++ tools/public_api_guard/components/core.api.md | 129 ++++- 8 files changed, 1203 insertions(+), 85 deletions(-) diff --git a/packages/components-dev/date-pipes/module.ts b/packages/components-dev/date-pipes/module.ts index 4aef6437f1..10fa1867e4 100644 --- a/packages/components-dev/date-pipes/module.ts +++ b/packages/components-dev/date-pipes/module.ts @@ -1,6 +1,12 @@ import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core'; import { KbqLuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; -import { DateAdapter, DateFormatter, KbqFormattersModule, KbqLocaleService } from '@koobiq/components/core'; +import { + DateAdapter, + DateFormatter, + KBQ_LOCALE_SERVICE, + KbqFormattersModule, + KbqLocaleService +} from '@koobiq/components/core'; import { DateTime } from 'luxon'; @Component({ @@ -14,7 +20,9 @@ import { DateTime } from 'luxon'; export class DevApp { protected readonly dateAdapter: DateAdapter = inject(DateAdapter); protected readonly formatter: DateFormatter = inject(DateFormatter); - protected readonly localeService: KbqLocaleService = inject(KbqLocaleService); + // The token, not the class: `KbqLuxonDateModule` provides it with `useClass`, so the `providedIn: 'root'` + // instance is a different object from the one the pipes and `DateFormatter` subscribe to. + protected readonly localeService: KbqLocaleService = inject(KBQ_LOCALE_SERVICE); obj: any = { absolute: { @@ -100,6 +108,15 @@ export class DevApp { endsNotCurrentYear: '' } } + }, + duration: { + seconds: '', + minutesSeconds: '', + hoursMinutes: '', + daysHours: '', + weeksDays: '', + monthsWeeks: '', + yearsMonths: '' } }; @@ -114,6 +131,30 @@ export class DevApp { this.populateRangeLong(); this.populateRangeMiddle(); this.populateRangeShort(); + this.populateDuration(); + } + + /** Switches between the two locales so the difference between the pipe families is visible. */ + toggleLocale() { + this.localeService.setLocale(this.localeService.id === 'ru-RU' ? 'en-US' : 'ru-RU'); + } + + populateDuration() { + const start = this.dateAdapter.today().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); + const ranges: Record = { + seconds: start.plus({ seconds: 21, milliseconds: 365 }), + minutesSeconds: start.plus({ minutes: 1, seconds: 25 }), + hoursMinutes: start.plus({ hours: 1, minutes: 21 }), + daysHours: start.plus({ days: 1, hours: 8, minutes: 25 }), + weeksDays: start.plus({ days: 15 }), + monthsWeeks: start.plus({ months: 1, days: 25 }), + yearsMonths: start.plus({ years: 3, months: 11 }) + }; + + Object.entries(ranges).forEach(([key, end]) => { + this.obj.duration[key] = [start, end]; + this.iso.duration[key] = [this.dateAdapter.toIso8601(start), this.dateAdapter.toIso8601(end)]; + }); } populateRangeShort() { diff --git a/packages/components-dev/date-pipes/template.html b/packages/components-dev/date-pipes/template.html index a133dc3aa8..2be994b29c 100644 --- a/packages/components-dev/date-pipes/template.html +++ b/packages/components-dev/date-pipes/template.html @@ -3,6 +3,14 @@

pipe's
+
+ +

+ Only the + kbq* + pipes below re-render on a locale change. The unprefixed ones are pure and stay as they were rendered first. +

+

Absolute date
@@ -656,4 +664,127 @@

Short format

+ +
+
Duration
+
+

Shortest format

+
+
+
Name
+
Default locale
+
+
+
kbqDurationShortest (seconds)
+
{{ obj.duration.seconds | kbqDurationShortest }}
+
+
+
kbqDurationShortest (seconds) (with milliseconds)
+
+ {{ obj.duration.seconds | kbqDurationShortest: { seconds: true, milliseconds: true } }} +
+
+
+
kbqDurationShortest (minutes and seconds)
+
{{ obj.duration.minutesSeconds | kbqDurationShortest }}
+
+
+
kbqDurationShortest (minutes and seconds) (without seconds)
+
{{ obj.duration.minutesSeconds | kbqDurationShortest: { seconds: false } }}
+
+
+
kbqDurationShortest (days and hours)
+
{{ obj.duration.daysHours | kbqDurationShortest }}
+
+
+
kbqDurationShortest (from ISO strings)
+
{{ iso.duration.daysHours | kbqDurationShortest }}
+
+
+
+
+

Long format

+
+
+
Name
+
Default locale
+
+
+
kbqDurationLong (seconds)
+
{{ obj.duration.seconds | kbqDurationLong }}
+
+
+
kbqDurationLong (minutes and seconds)
+
{{ obj.duration.minutesSeconds | kbqDurationLong }}
+
+
+
kbqDurationLong (hours and minutes)
+
{{ obj.duration.hoursMinutes | kbqDurationLong }}
+
+
+
kbqDurationLong (days and hours)
+
{{ obj.duration.daysHours | kbqDurationLong }}
+
+
+
kbqDurationLong (days and hours) (only hours)
+
{{ obj.duration.daysHours | kbqDurationLong: ['hours'] }}
+
+
+
kbqDurationLong (weeks and days)
+
{{ obj.duration.weeksDays | kbqDurationLong }}
+
+
+
kbqDurationLong (months and weeks)
+
{{ obj.duration.monthsWeeks | kbqDurationLong }}
+
+
+
kbqDurationLong (years and months)
+
{{ obj.duration.yearsMonths | kbqDurationLong }}
+
+
+
kbqDurationLong (years and months) (only years, with fraction)
+
{{ obj.duration.yearsMonths | kbqDurationLong: ['years'] : true }}
+
+
+
+
durationLong (pure, stale after a locale change)
+
{{ obj.duration.yearsMonths | durationLong }}
+
+
+
+
+

Short format

+
+
+
Name
+
Default locale
+
+
+
kbqDurationShort (seconds)
+
{{ obj.duration.seconds | kbqDurationShort }}
+
+
+
kbqDurationShort (seconds) (with milliseconds)
+
+ {{ obj.duration.seconds | kbqDurationShort: ['seconds', 'milliseconds'] }} +
+
+
+
kbqDurationShort (hours and minutes)
+
{{ obj.duration.hoursMinutes | kbqDurationShort }}
+
+
+
kbqDurationShort (days and hours)
+
{{ obj.duration.daysHours | kbqDurationShort }}
+
+
+
kbqDurationShort (weeks and days)
+
{{ obj.duration.weeksDays | kbqDurationShort }}
+
+
+
kbqDurationShort (years and months)
+
{{ obj.duration.yearsMonths | kbqDurationShort }}
+
+
+
diff --git a/packages/components/core/formatters/date/date-formatter.en.md b/packages/components/core/formatters/date/date-formatter.en.md index 10aaa41c9e..67e334d66c 100644 --- a/packages/components/core/formatters/date/date-formatter.en.md +++ b/packages/components/core/formatters/date/date-formatter.en.md @@ -1,17 +1,92 @@ -To format dates, you need to use DateFormatter methods, -for example: +DateFormatter is a unified system for formatting dates and times. It keeps the presentation consistent across the whole application and follows the corporate standards. + +DateFormatter tracks locale changes through KbqLocaleService on its own and updates the formats when the interface language changes. + +### Methods in TypeScript code + +DateFormatter methods format a date or a time directly in TypeScript code: ```typescript const formattedStringOfDate = this.formatter.absoluteLongDate(this.adapter.today()); ``` -You can also use pipe: +### Pipes in templates + +Formatting in HTML templates is done with pipes whose names correspond to the DateFormatter methods: + +```html +
{{ adapter.today() | kbqAbsoluteLongDate }}
+``` + +The pipes need `KbqFormattersModule` to be imported — it provides `DateFormatter` and exports every pipe. [Usage examples](https://github.com/koobiq/angular-components/tree/main/packages/components-dev/date-pipes) + +#### Which family to choose + +The same format is available in three flavours: + +| Family | Example | Behaviour | +| ------------------------ | ---------------------------- | -------------------------------------------------------------------------------------------------------- | +| `kbq*` — **recommended** | `kbqAbsoluteLongDate` | Recomputes on a KbqLocaleService locale change, caches the result by value, arguments and locale | +| No prefix | `absoluteLongDate` | A pure pipe. Recomputes only when the input value changes — **the string goes stale on a locale change** | +| `ImpurePipe` suffix | `absoluteLongDateImpurePipe` | An impure pipe with no cache: reformats on every change detection cycle | + +The unprefixed and `ImpurePipe` flavours are kept for backward compatibility. Use `kbq*` in new code. + +#### The pipes + +| Pipe | Input | Arguments | DateFormatter method | +| -------------------------- | ------------ | ---------------------------------------------- | ----------------------- | +| `kbqAbsoluteShortDate` | date | `currYear?: boolean` | `absoluteShortDate` | +| `kbqAbsoluteLongDate` | date | `currYear?: boolean` | `absoluteLongDate` | +| `kbqAbsoluteShortDateTime` | date | `options?: DateTimeOptions` | `absoluteShortDateTime` | +| `kbqAbsoluteLongDateTime` | date | `options?: DateTimeOptions` | `absoluteLongDateTime` | +| `kbqRelativeShortDate` | date | — | `relativeShortDate` | +| `kbqRelativeLongDate` | date | — | `relativeLongDate` | +| `kbqRelativeShortDateTime` | date | `options?: DateTimeOptions` | `relativeShortDateTime` | +| `kbqRelativeLongDateTime` | date | `options?: DateTimeOptions` | `relativeLongDateTime` | +| `kbqRangeShortDate` | `[from, to]` | — | `rangeShortDate` | +| `kbqRangeLongDate` | `[from, to]` | — | `rangeLongDate` | +| `kbqRangeShortDateTime` | `[from, to]` | `options?: DateTimeOptions` | `rangeShortDateTime` | +| `kbqRangeMiddleDateTime` | `[from, to]` | `options?: DateTimeOptions` | `rangeMiddleDateTime` | +| `kbqRangeLongDateTime` | `[from, to]` | `options?: DateTimeOptions` | `rangeLongDateTime` | +| `kbqDurationShortest` | `[from, to]` | `options?: DateTimeOptions` | `durationShortest` | +| `kbqDurationShort` | `[from, to]` | `units?: DurationUnit[]`, `fraction?: boolean` | `durationShort` | +| `kbqDurationLong` | `[from, to]` | `units?: DurationUnit[]`, `fraction?: boolean` | `durationLong` | + +`DateTimeOptions` is `{ seconds?: boolean; milliseconds?: boolean; currYear?: boolean }`. `kbqDurationShortest` uses only `seconds` (`true` by default) and `milliseconds` from it. ```html -
{{ adapter.today() | absoluteLongDate }}
+
{{ [task.startedAt, task.finishedAt] | kbqDurationShortest }}
+
{{ [task.startedAt, task.finishedAt] | kbqDurationLong: ['hours', 'minutes'] }}
+``` + +#### Opened ranges + +Pass `null` instead of one of the bounds and the range pipe switches to the opened-range format ("From January 15", "Until June 20") on its own. No separate pipe is needed for that. + +```html +
{{ [filter.from, filter.to] | kbqRangeLongDate }}
+``` + +`kbqRangeMiddleDateTime` is the exception: the middle format has no opened-range template, so it requires both bounds. + +#### Empty and invalid values + +When a date is missing or cannot be parsed, the pipe renders an empty string. For ranges that applies when both bounds are empty; the duration pipes additionally render an empty string when the start is later than the end. Call the `DateFormatter` methods directly if you need to be told about the error instead of hiding it — they throw. + +#### Custom formats + +Formats that have no pipe are available through `DateFormatter`: its public `config` field holds the templates of the active locale. + +```typescript +private readonly formatter = inject>(DateFormatter); + +format(from: DateTime, to: DateTime): string { + return this.formatter.rangeDate(from, to, this.formatter.config.rangeTemplates.closedRange.middle); +} ``` -The pipe name (absoluteLongDate) corresponds to the name of the DateFormatter method. Examples of usage can be found [here](https://github.com/koobiq/angular-components/tree/main/packages/components-dev/date-pipes) +`absoluteDate`, `relativeDate`, `rangeDateTime`, `duration` and `openedRangeDate` work the same way — they take a template as an argument. ### Absolute date diff --git a/packages/components/core/formatters/date/date-formatter.ru.md b/packages/components/core/formatters/date/date-formatter.ru.md index 65a1ae81f0..4f1e488a86 100644 --- a/packages/components/core/formatters/date/date-formatter.ru.md +++ b/packages/components/core/formatters/date/date-formatter.ru.md @@ -1,6 +1,6 @@ -DateFormatter — унифицированная система форматирования дат и времени. Она обеспечивает единообразное отображение во всех частях приложения и соответствует корпоративным стандартам. +DateFormatter — унифицированная система форматирования дат и времени. Она обеспечивает единообразное отображение во всех частях приложения и соответствует корпоративным стандартам. -DateFormatter автоматически отслеживает изменения локали через KbqLocaleService и обновляет форматы при смене языка интерфейса. +DateFormatter автоматически отслеживает изменения локали через KbqLocaleService и обновляет форматы при смене языка интерфейса. ### Методы в TypeScript-коде @@ -12,13 +12,81 @@ const formattedStringOfDate = this.formatter.absoluteLongDate(this.adapter.today ### Pipe в шаблонах -Для форматирования в HTML-шаблонах предназначен специальный pipe: +Для форматирования в HTML-шаблонах предназначены pipe, названия которых соответствуют методам DateFormatter: ```html -
{{ adapter.today() | absoluteLongDate }}
+
{{ adapter.today() | kbqAbsoluteLongDate }}
``` -Названия pipe полностью соответствуют методам DateFormatter. [Примеры использования](https://github.com/koobiq/angular-components/tree/main/packages/components-dev/date-pipes) +Чтобы pipe работали, нужно импортировать `KbqFormattersModule` — он предоставляет `DateFormatter` и экспортирует все pipe. [Примеры использования](https://github.com/koobiq/angular-components/tree/main/packages/components-dev/date-pipes) + +#### Какое семейство выбрать + +Один и тот же формат доступен в трёх вариантах: + +| Семейство | Пример | Поведение | +| -------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `kbq*` — **рекомендуется** | `kbqAbsoluteLongDate` | Пересчитывается при смене локали через KbqLocaleService, результат кэшируется по значению, аргументам и локали | +| Без префикса | `absoluteLongDate` | Pure pipe. Пересчитывается только при изменении входного значения — **при смене локали строка не обновится** | +| С суффиксом `ImpurePipe` | `absoluteLongDateImpurePipe` | Impure pipe без кэша: форматирует заново на каждом цикле проверки изменений | + +Беспрефиксные и `ImpurePipe`-варианты сохранены для обратной совместимости. В новом коде используйте `kbq*`. + +#### Список pipe + +| Pipe | Входное значение | Аргументы | Метод DateFormatter | +| -------------------------- | ---------------- | ---------------------------------------------- | ----------------------- | +| `kbqAbsoluteShortDate` | дата | `currYear?: boolean` | `absoluteShortDate` | +| `kbqAbsoluteLongDate` | дата | `currYear?: boolean` | `absoluteLongDate` | +| `kbqAbsoluteShortDateTime` | дата | `options?: DateTimeOptions` | `absoluteShortDateTime` | +| `kbqAbsoluteLongDateTime` | дата | `options?: DateTimeOptions` | `absoluteLongDateTime` | +| `kbqRelativeShortDate` | дата | — | `relativeShortDate` | +| `kbqRelativeLongDate` | дата | — | `relativeLongDate` | +| `kbqRelativeShortDateTime` | дата | `options?: DateTimeOptions` | `relativeShortDateTime` | +| `kbqRelativeLongDateTime` | дата | `options?: DateTimeOptions` | `relativeLongDateTime` | +| `kbqRangeShortDate` | `[от, до]` | — | `rangeShortDate` | +| `kbqRangeLongDate` | `[от, до]` | — | `rangeLongDate` | +| `kbqRangeShortDateTime` | `[от, до]` | `options?: DateTimeOptions` | `rangeShortDateTime` | +| `kbqRangeMiddleDateTime` | `[от, до]` | `options?: DateTimeOptions` | `rangeMiddleDateTime` | +| `kbqRangeLongDateTime` | `[от, до]` | `options?: DateTimeOptions` | `rangeLongDateTime` | +| `kbqDurationShortest` | `[от, до]` | `options?: DateTimeOptions` | `durationShortest` | +| `kbqDurationShort` | `[от, до]` | `units?: DurationUnit[]`, `fraction?: boolean` | `durationShort` | +| `kbqDurationLong` | `[от, до]` | `units?: DurationUnit[]`, `fraction?: boolean` | `durationLong` | + +`DateTimeOptions` — это `{ seconds?: boolean; milliseconds?: boolean; currYear?: boolean }`. `kbqDurationShortest` использует из него только `seconds` (по умолчанию `true`) и `milliseconds`. + +```html +
{{ [task.startedAt, task.finishedAt] | kbqDurationShortest }}
+
{{ [task.startedAt, task.finishedAt] | kbqDurationLong: ['hours', 'minutes'] }}
+``` + +#### Открытые диапазоны + +Передайте `null` вместо одной из границ — pipe диапазона сам переключится на формат открытого диапазона («С 15 января», «По 20 июня»). Отдельного pipe для этого не требуется. + +```html +
{{ [filter.from, filter.to] | kbqRangeLongDate }}
+``` + +Исключение — `kbqRangeMiddleDateTime`: у среднего формата нет шаблона открытого диапазона, поэтому он требует обе границы. + +#### Пустые и некорректные значения + +Если дату не удалось разобрать или она отсутствует, pipe выводит пустую строку. Для диапазонов это относится к случаю, когда обе границы пустые; pipe продолжительности, кроме того, выводит пустую строку, если начало позже конца. Если нужно узнать об ошибке, а не скрыть её, вызывайте методы `DateFormatter` напрямую — они бросают исключение. + +#### Нестандартные форматы + +Форматы, которых нет среди pipe, доступны через `DateFormatter`: у него есть публичное поле `config` с шаблонами текущей локали. + +```typescript +private readonly formatter = inject>(DateFormatter); + +format(from: DateTime, to: DateTime): string { + return this.formatter.rangeDate(from, to, this.formatter.config.rangeTemplates.closedRange.middle); +} +``` + +Так же работают `absoluteDate`, `relativeDate`, `rangeDateTime`, `duration` и `openedRangeDate` — они принимают шаблон аргументом. ### Доступные форматы diff --git a/packages/components/core/formatters/date/formatter.pipe.ts b/packages/components/core/formatters/date/formatter.pipe.ts index c26a1195f7..118c744100 100644 --- a/packages/components/core/formatters/date/formatter.pipe.ts +++ b/packages/components/core/formatters/date/formatter.pipe.ts @@ -1,10 +1,66 @@ import { ChangeDetectorRef, inject, Pipe, PipeTransform } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DurationUnit } from '@koobiq/date-adapter'; import { DateTimeOptions } from '@koobiq/date-formatter'; import { DateAdapter } from '../../datetime'; import { KBQ_LOCALE_SERVICE } from '../../locales'; import { DateFormatter } from './formatter'; +/** + * Identity comparison with a one-level element-wise fallback for arrays, so an input rebuilt on every + * change detection cycle — a `[from, to]` tuple returned from a getter or a `computed()`, a `units` + * array built in a method — still hits the cache of the impure pipes below. Array literals written + * directly in a template are already memoized by Angular (`ɵɵpureFunction`); this covers the rest. + */ +const shallowEqual = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + + return a.every((item, index) => item === b[index]); +}; + +/** + * Deserializes a pipe input into a date, treating a missing value and an unparseable one alike as + * "no date" — `DateAdapter.deserialize()` returns `null` for the former but a truthy *invalid* date + * for the latter, so a plain truthiness check is not enough. + */ +const toValidDate = (adapter: DateAdapter, value: unknown): D | null => { + const date = adapter.deserialize(value); + + return date != null && adapter.isValid(date) ? date : null; +}; + +/** A `[from, to]` tuple with both bounds required; `null` when either is missing or invalid. */ +const toClosedRange = (adapter: DateAdapter, [from, to]: D[] | string[]): [D, D] | null => { + const startDate = toValidDate(adapter, from); + const endDate = toValidDate(adapter, to); + + return startDate && endDate ? [startDate, endDate] : null; +}; + +/** + * A `[from, to]` tuple where one bound may be open; `null` only when neither bound is a valid date. + * + * The range formatters switch to the opened-range template on their own when a bound is missing, but + * throw when both are — and a throwing pipe aborts the rendering of the whole view. + */ +const toOpenedRange = (adapter: DateAdapter, [from, to]: D[] | string[]): [D | null, D | null] | null => { + const startDate = toValidDate(adapter, from); + const endDate = toValidDate(adapter, to); + + return startDate || endDate ? [startDate, endDate] : null; +}; + +/** + * A `[from, to]` tuple the duration formatters accept: both bounds required and chronologically + * ordered. `DateFormatter.duration*` throws on anything else. + */ +const toDurationRange = (adapter: DateAdapter, value: D[] | string[]): [D, D] | null => { + const range = toClosedRange(adapter, value); + + return range && adapter.compareDateTime(range[0], range[1]) <= 0 ? range : null; +}; + export class BaseFormatterPipe { protected readonly adapter: DateAdapter = inject(DateAdapter); protected readonly formatter: DateFormatter = inject(DateFormatter); @@ -55,7 +111,7 @@ export abstract class BaseLocaleAwareFormatterPipe< if ( this.hasCache && - value === this.cachedValue && + shallowEqual(value, this.cachedValue) && currentLocaleId === this.cachedLocaleId && this.argsEqual(args) ) { @@ -77,7 +133,7 @@ export abstract class BaseLocaleAwareFormatterPipe< if (args.length !== this.cachedArgs.length) return false; for (let i = 0; i < args.length; i++) { - if (args[i] !== this.cachedArgs[i]) return false; + if (!shallowEqual(args[i], this.cachedArgs[i])) return false; } return true; @@ -264,11 +320,12 @@ export class RelativeShortDateTimeFormatterImpurePipe extends RelativeShortDa name: 'rangeLongDate' }) export class RangeDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform([value1, value2]: D[] | string[]): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + transform(value: D[] | string[]): string { + const range = toOpenedRange(this.adapter, value); - return this.formatter.rangeLongDate(date1 as D, date2 as D); + if (!range) return ''; + + return this.formatter.rangeLongDate(range[0], range[1]); } } @@ -278,8 +335,8 @@ export class RangeDateFormatterPipe extends BaseFormatterPipe implements P }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeDateFormatterImpurePipe extends RangeDateFormatterPipe { - transform([value1, value2]: D[] | string[]): string { - return super.transform([value1, value2] as D[] | string[]); + transform(value: D[] | string[]): string { + return super.transform(value); } } @@ -287,11 +344,12 @@ export class RangeDateFormatterImpurePipe extends RangeDateFormatterPipe { name: 'rangeShortDate' }) export class RangeShortDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform([value1, value2]: D[] | string[]): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + transform(value: D[] | string[]): string { + const range = toOpenedRange(this.adapter, value); - return this.formatter.rangeShortDate(date1 as D, date2 as D); + if (!range) return ''; + + return this.formatter.rangeShortDate(range[0], range[1] ?? undefined); } } @@ -301,8 +359,8 @@ export class RangeShortDateFormatterPipe extends BaseFormatterPipe impleme }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeShortDateFormatterImpurePipe extends RangeShortDateFormatterPipe { - transform([value1, value2]: D[] | string[]): string { - return super.transform([value1, value2] as D[] | string[]); + transform(value: D[] | string[]): string { + return super.transform(value); } } @@ -310,11 +368,12 @@ export class RangeShortDateFormatterImpurePipe extends RangeShortDateFormatte name: 'rangeLongDateTime' }) export class RangeDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + transform(value: D[] | string[], options?: DateTimeOptions): string { + const range = toOpenedRange(this.adapter, value); - return this.formatter.rangeLongDateTime(date1 as D, date2 as D, options); + if (!range) return ''; + + return this.formatter.rangeLongDateTime(range[0], range[1] ?? undefined, options); } } @@ -324,8 +383,8 @@ export class RangeDateTimeFormatterPipe extends BaseFormatterPipe implemen }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeDateTimeFormatterImpurePipe extends RangeDateTimeFormatterPipe { - transform([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - return super.transform([value1, value2] as D[] | string[], options); + transform(value: D[] | string[], options?: DateTimeOptions): string { + return super.transform(value, options); } } @@ -333,11 +392,13 @@ export class RangeDateTimeFormatterImpurePipe extends RangeDateTimeFormatterP name: 'rangeMiddleDateTime' }) export class RangeMiddleDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + transform(value: D[] | string[], options?: DateTimeOptions): string { + // Unlike the other range formats, the middle one has no opened-range template — both bounds required. + const range = toClosedRange(this.adapter, value); - return this.formatter.rangeMiddleDateTime(date1 as D, date2 as D, options); + if (!range) return ''; + + return this.formatter.rangeMiddleDateTime(range[0], range[1], options); } } @@ -347,8 +408,8 @@ export class RangeMiddleDateTimeFormatterPipe extends BaseFormatterPipe im }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeMiddleDateTimeFormatterImpurePipe extends RangeMiddleDateTimeFormatterPipe { - transform([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - return super.transform([value1, value2] as D[] | string[], options); + transform(value: D[] | string[], options?: DateTimeOptions): string { + return super.transform(value, options); } } @@ -356,11 +417,12 @@ export class RangeMiddleDateTimeFormatterImpurePipe extends RangeMiddleDateTi name: 'rangeShortDateTime' }) export class RangeShortDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + transform(value: D[] | string[], options?: DateTimeOptions): string { + const range = toOpenedRange(this.adapter, value); - return this.formatter.rangeShortDateTime(date1 as D, date2 as D, options); + if (!range) return ''; + + return this.formatter.rangeShortDateTime(range[0], range[1], options); } } @@ -370,8 +432,80 @@ export class RangeShortDateTimeFormatterPipe extends BaseFormatterPipe imp }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeShortDateTimeFormatterImpurePipe extends RangeShortDateTimeFormatterPipe { - transform([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - return super.transform([value1, value2] as D[] | string[], options); + transform(value: D[] | string[], options?: DateTimeOptions): string { + return super.transform(value, options); + } +} + +@Pipe({ + name: 'durationShortest' +}) +export class DurationShortestFormatterPipe extends BaseFormatterPipe implements PipeTransform { + transform(value: D[] | string[], options?: DateTimeOptions): string { + const range = toDurationRange(this.adapter, value); + + if (!range) return ''; + + return this.formatter.durationShortest(range[0], range[1], options?.seconds, options?.milliseconds); + } +} + +@Pipe({ + name: 'durationShortestImpurePipe', + pure: false +}) +// eslint-disable-next-line @angular-eslint/use-pipe-transform-interface +export class DurationShortestFormatterImpurePipe extends DurationShortestFormatterPipe { + transform(value: D[] | string[], options?: DateTimeOptions): string { + return super.transform(value, options); + } +} + +@Pipe({ + name: 'durationLong' +}) +export class DurationLongFormatterPipe extends BaseFormatterPipe implements PipeTransform { + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + const range = toDurationRange(this.adapter, value); + + if (!range) return ''; + + return this.formatter.durationLong(range[0], range[1], units, fraction); + } +} + +@Pipe({ + name: 'durationLongImpurePipe', + pure: false +}) +// eslint-disable-next-line @angular-eslint/use-pipe-transform-interface +export class DurationLongFormatterImpurePipe extends DurationLongFormatterPipe { + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + return super.transform(value, units, fraction); + } +} + +@Pipe({ + name: 'durationShort' +}) +export class DurationShortFormatterPipe extends BaseFormatterPipe implements PipeTransform { + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + const range = toDurationRange(this.adapter, value); + + if (!range) return ''; + + return this.formatter.durationShort(range[0], range[1], units, fraction); + } +} + +@Pipe({ + name: 'durationShortImpurePipe', + pure: false +}) +// eslint-disable-next-line @angular-eslint/use-pipe-transform-interface +export class DurationShortFormatterImpurePipe extends DurationShortFormatterPipe { + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + return super.transform(value, units, fraction); } } @@ -543,11 +677,12 @@ export class KbqRangeLongDatePipe return super.transform(value); } - protected format([value1, value2]: D[] | string[]): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + protected format(value: D[] | string[]): string { + const range = toOpenedRange(this.adapter, value); + + if (!range) return ''; - return this.formatter.rangeLongDate(date1 as D, date2 as D); + return this.formatter.rangeLongDate(range[0], range[1]); } } @@ -563,11 +698,12 @@ export class KbqRangeShortDatePipe return super.transform(value); } - protected format([value1, value2]: D[] | string[]): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + protected format(value: D[] | string[]): string { + const range = toOpenedRange(this.adapter, value); - return this.formatter.rangeShortDate(date1 as D, date2 as D); + if (!range) return ''; + + return this.formatter.rangeShortDate(range[0], range[1] ?? undefined); } } @@ -583,11 +719,12 @@ export class KbqRangeLongDateTimePipe return super.transform(value, options); } - protected format([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + protected format(value: D[] | string[], options?: DateTimeOptions): string { + const range = toOpenedRange(this.adapter, value); + + if (!range) return ''; - return this.formatter.rangeLongDateTime(date1 as D, date2 as D, options); + return this.formatter.rangeLongDateTime(range[0], range[1] ?? undefined, options); } } @@ -603,11 +740,13 @@ export class KbqRangeMiddleDateTimePipe return super.transform(value, options); } - protected format([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + protected format(value: D[] | string[], options?: DateTimeOptions): string { + // Unlike the other range formats, the middle one has no opened-range template — both bounds required. + const range = toClosedRange(this.adapter, value); - return this.formatter.rangeMiddleDateTime(date1 as D, date2 as D, options); + if (!range) return ''; + + return this.formatter.rangeMiddleDateTime(range[0], range[1], options); } } @@ -623,10 +762,119 @@ export class KbqRangeShortDateTimePipe return super.transform(value, options); } - protected format([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + protected format(value: D[] | string[], options?: DateTimeOptions): string { + const range = toOpenedRange(this.adapter, value); + + if (!range) return ''; + + return this.formatter.rangeShortDateTime(range[0], range[1], options); + } +} + +/** + * Formats the duration between two dates as a digital-clock value, e.g. `48:02:25`. + * + * Takes a `[from, to]` tuple, like the range pipes. `options.seconds` defaults to `true` and + * `options.milliseconds` to `false`; `options.currYear` is not used by this format. + * + * Renders an empty string when a bound is missing or invalid, or when `from` is later than `to`. + * + * @example + * ```html + * {{ [startedAt, finishedAt] | kbqDurationShortest }} + * {{ [startedAt, finishedAt] | kbqDurationShortest: { seconds: false } }} + * ``` + */ +@Pipe({ + name: 'kbqDurationShortest', + pure: false +}) +export class KbqDurationShortestPipe + extends BaseLocaleAwareFormatterPipe + implements PipeTransform +{ + override transform(value: D[] | string[], options?: DateTimeOptions): string { + return super.transform(value, options); + } + + protected format(value: D[] | string[], options?: DateTimeOptions): string { + const range = toDurationRange(this.adapter, value); + + if (!range) return ''; + + return this.formatter.durationShortest(range[0], range[1], options?.seconds, options?.milliseconds); + } +} + +/** + * Formats the duration between two dates in the long text format, e.g. `2 дня и 4 часа`. + * + * Takes a `[from, to]` tuple, like the range pipes. `units` restricts the units to show (the + * formatter picks them automatically when omitted), `fraction` adds a fractional part for years + * and months. + * + * Renders an empty string when a bound is missing or invalid, or when `from` is later than `to`. + * + * @example + * ```html + * {{ [startedAt, finishedAt] | kbqDurationLong }} + * {{ [startedAt, finishedAt] | kbqDurationLong: ['hours', 'minutes'] }} + * {{ [startedAt, finishedAt] | kbqDurationLong: ['years'] : true }} + * ``` + */ +@Pipe({ + name: 'kbqDurationLong', + pure: false +}) +export class KbqDurationLongPipe + extends BaseLocaleAwareFormatterPipe + implements PipeTransform +{ + override transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + return super.transform(value, units, fraction); + } + + protected format(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + const range = toDurationRange(this.adapter, value); + + if (!range) return ''; + + return this.formatter.durationLong(range[0], range[1], units, fraction); + } +} + +/** + * Formats the duration between two dates in the short text format, e.g. `2 д 4 ч`. + * + * Takes a `[from, to]` tuple, like the range pipes. `units` restricts the units to show (the + * formatter picks them automatically when omitted), `fraction` adds a fractional part for years + * and months. + * + * Renders an empty string when a bound is missing or invalid, or when `from` is later than `to`. + * + * @example + * ```html + * {{ [startedAt, finishedAt] | kbqDurationShort }} + * {{ [startedAt, finishedAt] | kbqDurationShort: ['seconds', 'milliseconds'] }} + * ``` + */ +@Pipe({ + name: 'kbqDurationShort', + pure: false +}) +export class KbqDurationShortPipe + extends BaseLocaleAwareFormatterPipe + implements PipeTransform +{ + override transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + return super.transform(value, units, fraction); + } + + protected format(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + const range = toDurationRange(this.adapter, value); + + if (!range) return ''; - return this.formatter.rangeShortDateTime(date1 as D, date2 as D, options); + return this.formatter.durationShort(range[0], range[1], units, fraction); } } diff --git a/packages/components/core/formatters/date/formatter.spec.ts b/packages/components/core/formatters/date/formatter.spec.ts index b6c161e50b..eaf83c6bbf 100644 --- a/packages/components/core/formatters/date/formatter.spec.ts +++ b/packages/components/core/formatters/date/formatter.spec.ts @@ -1,9 +1,15 @@ -import { ChangeDetectionStrategy, Component, LOCALE_ID, signal } from '@angular/core'; -import { inject, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, LOCALE_ID, signal } from '@angular/core'; +import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { KbqLuxonDateModule, LuxonDateAdapter, LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { DateAdapter, DateFormatter, + DurationLongFormatterImpurePipe, + DurationLongFormatterPipe, + DurationShortestFormatterImpurePipe, + DurationShortestFormatterPipe, + DurationShortFormatterImpurePipe, + DurationShortFormatterPipe, KBQ_DEFAULT_LOCALE_DATA_FACTORY, KBQ_LOCALE_DATA, KBQ_LOCALE_ID, @@ -12,6 +18,9 @@ import { KbqAbsoluteLongDateTimePipe, KbqAbsoluteShortDatePipe, KbqAbsoluteShortDateTimePipe, + KbqDurationLongPipe, + KbqDurationShortestPipe, + KbqDurationShortPipe, KbqFormattersModule, KbqLocaleService, KbqRangeLongDatePipe, @@ -22,10 +31,28 @@ import { KbqRelativeLongDatePipe, KbqRelativeLongDateTimePipe, KbqRelativeShortDatePipe, - KbqRelativeShortDateTimePipe + KbqRelativeShortDateTimePipe, + RangeDateFormatterPipe, + RangeDateTimeFormatterPipe, + RangeMiddleDateTimeFormatterPipe, + RangeShortDateFormatterPipe, + RangeShortDateTimeFormatterPipe } from '@koobiq/components/core'; import { DateTime, DateTimeUnit } from 'luxon'; +/** + * Runs a change detection cycle on an OnPush host that nothing else marked dirty, so that impure pipes + * actually get their `transform` called. Without it, `detectChanges()` refreshes the host view but skips + * the clean OnPush component view, and any assertion about caching passes vacuously. + * + * `fixture.changeDetectorRef` is the host view's ref, which is why the component view's own one — the + * same one `BaseLocaleAwareFormatterPipe` injects — has to be pulled from the component injector. + */ +const refresh = (fixture: ComponentFixture) => { + fixture.componentRef.injector.get(ChangeDetectorRef).markForCheck(); + fixture.detectChanges(); +}; + describe('Date formatter', () => { let adapter: LuxonDateAdapter; let formatter: DateFormatter; @@ -2436,8 +2463,8 @@ describe('Date formatter (imports and providing)', () => { const spy = jest.spyOn(dateFormatter, 'absoluteLongDate'); fixture.detectChanges(); - fixture.detectChanges(); - fixture.detectChanges(); + refresh(fixture); + refresh(fixture); expect(spy).toHaveBeenCalledTimes(1); }); @@ -2569,4 +2596,408 @@ describe('Date formatter (imports and providing)', () => { expect(read('absLong')).not.toBe(ruAbsLong); }); }); + + describe('Duration date pipes', () => { + @Component({ + selector: 'kbq-duration-pipes-host', + imports: [KbqDurationShortestPipe, KbqDurationLongPipe, KbqDurationShortPipe], + template: ` + {{ range() | kbqDurationShortest }} + {{ range() | kbqDurationShortest: { seconds: false } }} + {{ range() | kbqDurationShortest: { seconds: true, milliseconds: true } }} + {{ range() | kbqDurationLong }} + {{ range() | kbqDurationLong: ['hours', 'minutes'] }} + {{ range() | kbqDurationLong: ['years'] : true }} + {{ range() | kbqDurationShort }} + {{ range() | kbqDurationShort: ['seconds', 'milliseconds'] }} + `, + changeDetection: ChangeDetectionStrategy.OnPush + }) + class DurationPipesHostComponent { + readonly range = signal<(DateTime | string | null)[]>([]); + } + + // The legacy families share the formatting code with the `kbq*` ones but not the locale reactivity: + // the pure pipe only recomputes when its input changes, the impure one on every change detection cycle. + @Component({ + selector: 'kbq-legacy-duration-pipes-host', + imports: [ + DurationShortestFormatterPipe, + DurationLongFormatterPipe, + DurationShortFormatterPipe, + DurationShortestFormatterImpurePipe, + DurationLongFormatterImpurePipe, + DurationShortFormatterImpurePipe + ], + template: ` + {{ range() | durationShortest }} + {{ range() | durationLong }} + {{ range() | durationShort }} + {{ range() | durationShortestImpurePipe }} + {{ range() | durationLongImpurePipe }} + {{ range() | durationShortImpurePipe }} + `, + changeDetection: ChangeDetectionStrategy.OnPush + }) + class LegacyDurationPipesHostComponent { + readonly range = signal<(DateTime | string | null)[]>([]); + } + + @Component({ + selector: 'kbq-duration-cache-host', + imports: [KbqDurationLongPipe], + template: '{{ range() | kbqDurationLong }}', + changeDetection: ChangeDetectionStrategy.OnPush + }) + class DurationCacheHostComponent { + readonly range = signal([]); + } + + let localeService: KbqLocaleService; + let dateFormatter: DateFormatter; + let testAdapter: LuxonDateAdapter; + let start: DateTime; + let end: DateTime; + + // Each span's expected output equals the matching DateFormatter call in the active locale. + const durationExpect: Record, d1: DateTime, d2: DateTime) => string> = { + shortest: (f, d1, d2) => f.durationShortest(d1, d2), + shortestNoSeconds: (f, d1, d2) => f.durationShortest(d1, d2, false), + shortestMs: (f, d1, d2) => f.durationShortest(d1, d2, true, true), + long: (f, d1, d2) => f.durationLong(d1, d2), + longUnits: (f, d1, d2) => f.durationLong(d1, d2, ['hours', 'minutes']), + longFraction: (f, d1, d2) => f.durationLong(d1, d2, ['years'], true), + short: (f, d1, d2) => f.durationShort(d1, d2), + shortUnits: (f, d1, d2) => f.durationShort(d1, d2, ['seconds', 'milliseconds']) + }; + const legacyDurationExpect: Record, d1: DateTime, d2: DateTime) => string> = + { + pureShortest: (f, d1, d2) => f.durationShortest(d1, d2), + pureLong: (f, d1, d2) => f.durationLong(d1, d2), + pureShort: (f, d1, d2) => f.durationShort(d1, d2), + impureShortest: (f, d1, d2) => f.durationShortest(d1, d2), + impureLong: (f, d1, d2) => f.durationLong(d1, d2), + impureShort: (f, d1, d2) => f.durationShort(d1, d2) + }; + + const read = (fixture: ComponentFixture, id: string): string => + fixture.nativeElement.querySelector(`#${id}`).textContent.trim(); + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + DurationPipesHostComponent, + LegacyDurationPipesHostComponent, + DurationCacheHostComponent, + KbqFormattersModule, + KbqLuxonDateModule + ], + providers: [ + { provide: KBQ_LOCALE_ID, useValue: 'ru-RU' }, + { provide: KBQ_LOCALE_DATA, useValue: KBQ_DEFAULT_LOCALE_DATA_FACTORY() }, + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } + ] + }); + }); + + beforeEach(inject( + [DateAdapter, DateFormatter, KBQ_LOCALE_SERVICE], + (a: LuxonDateAdapter, f: DateFormatter, l: KbqLocaleService) => { + testAdapter = a; + dateFormatter = f; + localeService = l; + + start = testAdapter.createDateTime(2022, 0, 15, 10, 0, 0, 0); + end = start.plus({ years: 2, months: 6, hours: 5, minutes: 2, seconds: 25, milliseconds: 125 }); + } + )); + + it('renders every duration format', () => { + const fixture = TestBed.createComponent(DurationPipesHostComponent); + + fixture.componentInstance.range.set([start, end]); + fixture.detectChanges(); + + Object.entries(durationExpect).forEach(([id, fn]) => + expect(read(fixture, id)).toBe(fn(dateFormatter, start, end)) + ); + }); + + it('renders the same output through the legacy pure and impure families', () => { + const fixture = TestBed.createComponent(LegacyDurationPipesHostComponent); + + fixture.componentInstance.range.set([start, end]); + fixture.detectChanges(); + + Object.entries(legacyDurationExpect).forEach(([id, fn]) => + expect(read(fixture, id)).toBe(fn(dateFormatter, start, end)) + ); + }); + + it('recomputes every format when KbqLocaleService.setLocale changes the active locale', () => { + const fixture = TestBed.createComponent(DurationPipesHostComponent); + + fixture.componentInstance.range.set([start, end]); + fixture.detectChanges(); + + const ruLong = read(fixture, 'long'); + + localeService.setLocale('en-US'); + fixture.detectChanges(); + + Object.entries(durationExpect).forEach(([id, fn]) => + expect(read(fixture, id)).toBe(fn(dateFormatter, start, end)) + ); + expect(read(fixture, 'long')).not.toBe(ruLong); + }); + + // Neither legacy family marks its host for check, so on locale change they update only once the + // host is re-checked for some other reason — and then only the impure one picks up the new locale. + it('leaves the legacy pure family stale on locale change, unlike the impure one', () => { + const fixture = TestBed.createComponent(LegacyDurationPipesHostComponent); + + fixture.componentInstance.range.set([start, end]); + fixture.detectChanges(); + + const ruPureLong = read(fixture, 'pureLong'); + + localeService.setLocale('en-US'); + refresh(fixture); + + expect(read(fixture, 'pureLong')).toBe(ruPureLong); + expect(read(fixture, 'impureLong')).toBe(dateFormatter.durationLong(start, end)); + expect(read(fixture, 'impureLong')).not.toBe(ruPureLong); + }); + + it('caches the result and does not call the formatter on every CD tick', () => { + const fixture = TestBed.createComponent(DurationCacheHostComponent); + + fixture.componentInstance.range.set([start, end]); + fixture.detectChanges(); + + const spy = jest.spyOn(dateFormatter, 'durationLong'); + + refresh(fixture); + refresh(fixture); + refresh(fixture); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('recomputes when the input range changes', () => { + const fixture = TestBed.createComponent(DurationCacheHostComponent); + + fixture.componentInstance.range.set([start, end]); + fixture.detectChanges(); + + const spy = jest.spyOn(dateFormatter, 'durationLong'); + + fixture.componentInstance.range.set([start, end.plus({ days: 1 })]); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + // `DateFormatter.duration*` throws on all of these; a throwing pipe would abort the whole view. + describe('unformattable input', () => { + const cases: [string, (d: DateTime) => (DateTime | string | null)[]][] = [ + ['an empty tuple', () => []], + ['a missing start', (d) => [null, d]], + ['a missing end', (d) => [d, null]], + ['both bounds missing', () => [null, null]], + ['an unparseable bound', (d) => ['not-a-date', d]], + ['a reversed range', (d) => [d.plus({ days: 1 }), d]] + ]; + + it.each(cases)('renders an empty string for %s', (_, makeRange) => { + const fixture = TestBed.createComponent(DurationPipesHostComponent); + const legacyFixture = TestBed.createComponent(LegacyDurationPipesHostComponent); + + fixture.componentInstance.range.set(makeRange(start)); + legacyFixture.componentInstance.range.set(makeRange(start)); + + expect(() => { + fixture.detectChanges(); + legacyFixture.detectChanges(); + }).not.toThrow(); + + Object.keys(durationExpect).forEach((id) => expect(read(fixture, id)).toBe('')); + Object.keys(legacyDurationExpect).forEach((id) => expect(read(legacyFixture, id)).toBe('')); + }); + }); + }); + + describe('Date range pipes with missing bounds', () => { + @Component({ + selector: 'kbq-range-bounds-host', + imports: [ + KbqRangeLongDatePipe, + KbqRangeLongDateTimePipe, + KbqRangeMiddleDateTimePipe, + KbqRangeShortDatePipe, + KbqRangeShortDateTimePipe, + RangeDateFormatterPipe, + RangeDateTimeFormatterPipe, + RangeMiddleDateTimeFormatterPipe, + RangeShortDateFormatterPipe, + RangeShortDateTimeFormatterPipe + ], + template: ` + {{ range() | kbqRangeLongDate }} + {{ range() | kbqRangeLongDateTime }} + {{ range() | kbqRangeMiddleDateTime }} + {{ range() | kbqRangeShortDate }} + {{ range() | kbqRangeShortDateTime }} + {{ range() | rangeLongDate }} + {{ range() | rangeLongDateTime }} + {{ range() | rangeMiddleDateTime }} + {{ range() | rangeShortDate }} + {{ range() | rangeShortDateTime }} + `, + changeDetection: ChangeDetectionStrategy.OnPush + }) + class RangeBoundsHostComponent { + readonly range = signal<(DateTime | string | null)[]>([]); + } + + let dateFormatter: DateFormatter; + let testAdapter: LuxonDateAdapter; + + const allIds = [ + 'kbqLong', + 'kbqLongTime', + 'kbqMidTime', + 'kbqShort', + 'kbqShortTime', + 'pureLong', + 'pureLongTime', + 'pureMidTime', + 'pureShort', + 'pureShortTime' + ]; + + const read = (fixture: ComponentFixture, id: string): string => + fixture.nativeElement.querySelector(`#${id}`).textContent.trim(); + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [RangeBoundsHostComponent, KbqFormattersModule, KbqLuxonDateModule], + providers: [ + { provide: KBQ_LOCALE_ID, useValue: 'ru-RU' }, + { provide: KBQ_LOCALE_DATA, useValue: KBQ_DEFAULT_LOCALE_DATA_FACTORY() }, + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } + ] + }); + }); + + beforeEach(inject([DateAdapter, DateFormatter], (a: LuxonDateAdapter, f: DateFormatter) => { + testAdapter = a; + dateFormatter = f; + })); + + // `openedRangeDate` throws when neither bound is a date, which used to abort the rendering of the + // whole host view — reachable from a template as soon as a range form control is left empty. + it.each([ + ['an empty tuple', []], + ['both bounds missing', [null, null]], + ['both bounds unparseable', ['x', 'y']] + ])('renders an empty string for %s', (_, range) => { + const fixture = TestBed.createComponent(RangeBoundsHostComponent); + + fixture.componentInstance.range.set(range as (DateTime | string | null)[]); + + expect(() => fixture.detectChanges()).not.toThrow(); + + allIds.forEach((id) => expect(read(fixture, id)).toBe('')); + }); + + it('keeps formatting an opened range when only one bound is set', () => { + const fixture = TestBed.createComponent(RangeBoundsHostComponent); + const date = testAdapter.createDate(2024, 0, 15); + + fixture.componentInstance.range.set([date, null]); + fixture.detectChanges(); + + expect(read(fixture, 'kbqLong')).toBe(dateFormatter.rangeLongDate(date, null)); + expect(read(fixture, 'kbqShort')).toBe(dateFormatter.rangeShortDate(date)); + expect(read(fixture, 'pureLong')).toBe(dateFormatter.rangeLongDate(date, null)); + expect(read(fixture, 'pureShort')).toBe(dateFormatter.rangeShortDate(date)); + + // The middle format has no opened-range template, so it renders nothing instead of throwing. + expect(read(fixture, 'kbqMidTime')).toBe(''); + expect(read(fixture, 'pureMidTime')).toBe(''); + }); + }); + + describe('BaseLocaleAwareFormatterPipe caching', () => { + // A getter rebuilds the tuple on every change detection cycle; an array literal written directly in + // a template does not, because Angular memoizes it with `ɵɵpureFunction`. + @Component({ + selector: 'kbq-rebuilt-range-host', + imports: [KbqDurationLongPipe], + template: '{{ range | kbqDurationLong }}', + changeDetection: ChangeDetectionStrategy.OnPush + }) + class RebuiltRangeHostComponent { + from!: DateTime; + to!: DateTime; + + get range(): DateTime[] { + return [this.from, this.to]; + } + } + + let dateFormatter: DateFormatter; + let testAdapter: LuxonDateAdapter; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [RebuiltRangeHostComponent, KbqFormattersModule, KbqLuxonDateModule], + providers: [ + { provide: KBQ_LOCALE_ID, useValue: 'ru-RU' }, + { provide: KBQ_LOCALE_DATA, useValue: KBQ_DEFAULT_LOCALE_DATA_FACTORY() }, + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } + ] + }); + }); + + beforeEach(inject([DateAdapter, DateFormatter], (a: LuxonDateAdapter, f: DateFormatter) => { + testAdapter = a; + dateFormatter = f; + })); + + it('hits the cache for a tuple rebuilt on every CD tick', () => { + const fixture = TestBed.createComponent(RebuiltRangeHostComponent); + + fixture.componentInstance.from = testAdapter.createDateTime(2024, 0, 15, 10, 0, 0, 0); + fixture.componentInstance.to = fixture.componentInstance.from.plus({ days: 2, hours: 4 }); + fixture.detectChanges(); + + const spy = jest.spyOn(dateFormatter, 'durationLong'); + + refresh(fixture); + refresh(fixture); + refresh(fixture); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('recomputes when an element of the rebuilt tuple changes', () => { + const fixture = TestBed.createComponent(RebuiltRangeHostComponent); + + fixture.componentInstance.from = testAdapter.createDateTime(2024, 0, 15, 10, 0, 0, 0); + fixture.componentInstance.to = fixture.componentInstance.from.plus({ days: 2, hours: 4 }); + fixture.detectChanges(); + + const rendered = fixture.nativeElement.textContent.trim(); + + fixture.componentInstance.to = fixture.componentInstance.from.plus({ days: 9 }); + refresh(fixture); + + expect(fixture.nativeElement.textContent.trim()).not.toBe(rendered); + }); + }); }); diff --git a/packages/components/core/formatters/index.ts b/packages/components/core/formatters/index.ts index 520e794fad..8df73bec0e 100644 --- a/packages/components/core/formatters/index.ts +++ b/packages/components/core/formatters/index.ts @@ -9,10 +9,19 @@ import { AbsoluteDateTimeFormatterPipe, AbsoluteShortDateTimeFormatterImpurePipe, AbsoluteShortDateTimeFormatterPipe, + DurationLongFormatterImpurePipe, + DurationLongFormatterPipe, + DurationShortestFormatterImpurePipe, + DurationShortestFormatterPipe, + DurationShortFormatterImpurePipe, + DurationShortFormatterPipe, KbqAbsoluteLongDatePipe, KbqAbsoluteLongDateTimePipe, KbqAbsoluteShortDatePipe, KbqAbsoluteShortDateTimePipe, + KbqDurationLongPipe, + KbqDurationShortestPipe, + KbqDurationShortPipe, KbqRangeLongDatePipe, KbqRangeLongDateTimePipe, KbqRangeMiddleDateTimePipe, @@ -59,6 +68,9 @@ import { KbqDecimalPipe, KbqRoundDecimalPipe, KbqTableNumberPipe } from './numbe RangeDateTimeFormatterPipe, RangeShortDateTimeFormatterPipe, RangeMiddleDateTimeFormatterPipe, + DurationShortestFormatterPipe, + DurationLongFormatterPipe, + DurationShortFormatterPipe, AbsoluteDateFormatterImpurePipe, AbsoluteDateTimeFormatterImpurePipe, AbsoluteDateShortFormatterImpurePipe, @@ -72,6 +84,9 @@ import { KbqDecimalPipe, KbqRoundDecimalPipe, KbqTableNumberPipe } from './numbe RangeDateTimeFormatterImpurePipe, RangeShortDateTimeFormatterImpurePipe, RangeMiddleDateTimeFormatterImpurePipe, + DurationShortestFormatterImpurePipe, + DurationLongFormatterImpurePipe, + DurationShortFormatterImpurePipe, KbqAbsoluteLongDatePipe, KbqAbsoluteLongDateTimePipe, KbqAbsoluteShortDatePipe, @@ -85,6 +100,9 @@ import { KbqDecimalPipe, KbqRoundDecimalPipe, KbqTableNumberPipe } from './numbe KbqRangeMiddleDateTimePipe, KbqRangeShortDatePipe, KbqRangeShortDateTimePipe, + KbqDurationShortestPipe, + KbqDurationLongPipe, + KbqDurationShortPipe, KbqDataSizePipe, KbqDecimalPipe, KbqRoundDecimalPipe, @@ -108,6 +126,9 @@ import { KbqDecimalPipe, KbqRoundDecimalPipe, KbqTableNumberPipe } from './numbe RangeDateTimeFormatterPipe, RangeShortDateTimeFormatterPipe, RangeMiddleDateTimeFormatterPipe, + DurationShortestFormatterPipe, + DurationLongFormatterPipe, + DurationShortFormatterPipe, AbsoluteDateFormatterImpurePipe, AbsoluteDateTimeFormatterImpurePipe, AbsoluteDateShortFormatterImpurePipe, @@ -121,6 +142,9 @@ import { KbqDecimalPipe, KbqRoundDecimalPipe, KbqTableNumberPipe } from './numbe RangeDateTimeFormatterImpurePipe, RangeShortDateTimeFormatterImpurePipe, RangeMiddleDateTimeFormatterImpurePipe, + DurationShortestFormatterImpurePipe, + DurationLongFormatterImpurePipe, + DurationShortFormatterImpurePipe, KbqAbsoluteLongDatePipe, KbqAbsoluteLongDateTimePipe, KbqAbsoluteShortDatePipe, @@ -134,6 +158,9 @@ import { KbqDecimalPipe, KbqRoundDecimalPipe, KbqTableNumberPipe } from './numbe KbqRangeMiddleDateTimePipe, KbqRangeShortDatePipe, KbqRangeShortDateTimePipe, + KbqDurationShortestPipe, + KbqDurationLongPipe, + KbqDurationShortPipe, KbqDataSizePipe ] }) diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 73cdfc6be8..23a29d93c1 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -26,6 +26,7 @@ import { DateFormatter as DateFormatter_2 } from '@koobiq/date-formatter'; import { DateTimeOptions } from '@koobiq/date-formatter'; import { DestroyRef } from '@angular/core'; import { Directionality } from '@angular/cdk/bidi'; +import { DurationUnit } from '@koobiq/date-adapter'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; import { FlexibleConnectedPositionStrategy } from '@angular/cdk/overlay'; @@ -318,6 +319,66 @@ export function dispatchTouchEvent(node: Node, type: string, x?: number, y?: num // @public (undocumented) export const DOWN_ARROW = 40; +// @public (undocumented) +export class DurationLongFormatterImpurePipe extends DurationLongFormatterPipe { + // (undocumented) + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "durationLongImpurePipe", true>; +} + +// @public (undocumented) +export class DurationLongFormatterPipe extends BaseFormatterPipe implements PipeTransform { + // (undocumented) + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "durationLong", true>; +} + +// @public (undocumented) +export class DurationShortestFormatterImpurePipe extends DurationShortestFormatterPipe { + // (undocumented) + transform(value: D[] | string[], options?: DateTimeOptions): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "durationShortestImpurePipe", true>; +} + +// @public (undocumented) +export class DurationShortestFormatterPipe extends BaseFormatterPipe implements PipeTransform { + // (undocumented) + transform(value: D[] | string[], options?: DateTimeOptions): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "durationShortest", true>; +} + +// @public (undocumented) +export class DurationShortFormatterImpurePipe extends DurationShortFormatterPipe { + // (undocumented) + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "durationShortImpurePipe", true>; +} + +// @public (undocumented) +export class DurationShortFormatterPipe extends BaseFormatterPipe implements PipeTransform { + // (undocumented) + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "durationShort", true>; +} + // @public (undocumented) export const E = 69; @@ -2651,6 +2712,42 @@ export type KbqDefaultSizes = 'compact' | 'normal' | 'big'; // @public (undocumented) export const KbqDefaultThemes: KbqTheme[]; +// @public +export class KbqDurationLongPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { + // (undocumented) + protected format(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "kbqDurationLong", true>; +} + +// @public +export class KbqDurationShortestPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { + // (undocumented) + protected format(value: D[] | string[], options?: DateTimeOptions): string; + // (undocumented) + transform(value: D[] | string[], options?: DateTimeOptions): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "kbqDurationShortest", true>; +} + +// @public +export class KbqDurationShortPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { + // (undocumented) + protected format(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "kbqDurationShort", true>; +} + // @public export type KbqEnumValues = `${T}`; @@ -2718,7 +2815,7 @@ export class KbqFormattersModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -3388,7 +3485,7 @@ export type KbqPseudoCheckboxState = 'unchecked' | 'checked' | 'indeterminate' | // @public (undocumented) export class KbqRangeLongDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(input: D[] | string[]): string; + protected format(value: D[] | string[]): string; // (undocumented) transform(value: D[] | string[]): string; // (undocumented) @@ -3400,7 +3497,7 @@ export class KbqRangeLongDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(input: D[] | string[], options?: DateTimeOptions): string; + protected format(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) transform(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) @@ -3412,7 +3509,7 @@ export class KbqRangeLongDateTimePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(input: D[] | string[], options?: DateTimeOptions): string; + protected format(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) transform(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) @@ -3424,7 +3521,7 @@ export class KbqRangeMiddleDateTimePipe extends BaseLocaleAwareFormatterPipe< // @public (undocumented) export class KbqRangeShortDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(input: D[] | string[]): string; + protected format(value: D[] | string[]): string; // (undocumented) transform(value: D[] | string[]): string; // (undocumented) @@ -3436,7 +3533,7 @@ export class KbqRangeShortDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(input: D[] | string[], options?: DateTimeOptions): string; + protected format(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) transform(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) @@ -4379,7 +4476,7 @@ export const R = 82; // @public (undocumented) export class RangeDateFormatterImpurePipe extends RangeDateFormatterPipe { // (undocumented) - transform(input: D[] | string[]): string; + transform(value: D[] | string[]): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4389,7 +4486,7 @@ export class RangeDateFormatterImpurePipe extends RangeDateFormatterPipe { // @public (undocumented) export class RangeDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(input: D[] | string[]): string; + transform(value: D[] | string[]): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4399,7 +4496,7 @@ export class RangeDateFormatterPipe extends BaseFormatterPipe implements P // @public (undocumented) export class RangeDateTimeFormatterImpurePipe extends RangeDateTimeFormatterPipe { // (undocumented) - transform(input: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4409,7 +4506,7 @@ export class RangeDateTimeFormatterImpurePipe extends RangeDateTimeFormatterP // @public (undocumented) export class RangeDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(input: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4419,7 +4516,7 @@ export class RangeDateTimeFormatterPipe extends BaseFormatterPipe implemen // @public (undocumented) export class RangeMiddleDateTimeFormatterImpurePipe extends RangeMiddleDateTimeFormatterPipe { // (undocumented) - transform(input: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4429,7 +4526,7 @@ export class RangeMiddleDateTimeFormatterImpurePipe extends RangeMiddleDateTi // @public (undocumented) export class RangeMiddleDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(input: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4439,7 +4536,7 @@ export class RangeMiddleDateTimeFormatterPipe extends BaseFormatterPipe im // @public (undocumented) export class RangeShortDateFormatterImpurePipe extends RangeShortDateFormatterPipe { // (undocumented) - transform(input: D[] | string[]): string; + transform(value: D[] | string[]): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4449,7 +4546,7 @@ export class RangeShortDateFormatterImpurePipe extends RangeShortDateFormatte // @public (undocumented) export class RangeShortDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(input: D[] | string[]): string; + transform(value: D[] | string[]): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4459,7 +4556,7 @@ export class RangeShortDateFormatterPipe extends BaseFormatterPipe impleme // @public (undocumented) export class RangeShortDateTimeFormatterImpurePipe extends RangeShortDateTimeFormatterPipe { // (undocumented) - transform(input: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4469,7 +4566,7 @@ export class RangeShortDateTimeFormatterImpurePipe extends RangeShortDateTime // @public (undocumented) export class RangeShortDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(input: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[], options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) From 8575144a3bf40b3eb6cec85014859b365de64c85 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 4 Aug 2026 20:44:08 +0300 Subject: [PATCH 2/3] fix: after review --- .../core/formatters/date/date-formatter.en.md | 6 +- .../core/formatters/date/date-formatter.ru.md | 6 +- .../core/formatters/date/formatter.pipe.ts | 28 ++++- .../core/formatters/date/formatter.spec.ts | 112 ++++++++++++++++-- 4 files changed, 133 insertions(+), 19 deletions(-) diff --git a/packages/components/core/formatters/date/date-formatter.en.md b/packages/components/core/formatters/date/date-formatter.en.md index 67e334d66c..6fb00e0ade 100644 --- a/packages/components/core/formatters/date/date-formatter.en.md +++ b/packages/components/core/formatters/date/date-formatter.en.md @@ -72,7 +72,11 @@ Pass `null` instead of one of the bounds and the range pipe switches to the open #### Empty and invalid values -When a date is missing or cannot be parsed, the pipe renders an empty string. For ranges that applies when both bounds are empty; the duration pipes additionally render an empty string when the start is later than the end. Call the `DateFormatter` methods directly if you need to be told about the error instead of hiding it — they throw. +When a date is missing or cannot be parsed, the pipe renders an empty string. + +The range pipes that support an opened range — `kbqRangeShortDate`, `kbqRangeLongDate`, `kbqRangeShortDateTime`, `kbqRangeLongDateTime` — render an empty string only when both bounds are missing or invalid; a single valid bound switches them to the opened-range format instead. `kbqRangeMiddleDateTime` and the duration pipes need both bounds, so they render an empty string as soon as either one is missing or invalid; the duration pipes additionally render an empty string when the start is later than the end. + +Call the `DateFormatter` methods directly if you need to be told about the error instead of hiding it — they throw. #### Custom formats diff --git a/packages/components/core/formatters/date/date-formatter.ru.md b/packages/components/core/formatters/date/date-formatter.ru.md index 4f1e488a86..a64cee46ea 100644 --- a/packages/components/core/formatters/date/date-formatter.ru.md +++ b/packages/components/core/formatters/date/date-formatter.ru.md @@ -72,7 +72,11 @@ const formattedStringOfDate = this.formatter.absoluteLongDate(this.adapter.today #### Пустые и некорректные значения -Если дату не удалось разобрать или она отсутствует, pipe выводит пустую строку. Для диапазонов это относится к случаю, когда обе границы пустые; pipe продолжительности, кроме того, выводит пустую строку, если начало позже конца. Если нужно узнать об ошибке, а не скрыть её, вызывайте методы `DateFormatter` напрямую — они бросают исключение. +Если дату не удалось разобрать или она отсутствует, pipe выводит пустую строку. + +Pipe диапазонов, поддерживающие открытый диапазон, — `kbqRangeShortDate`, `kbqRangeLongDate`, `kbqRangeShortDateTime`, `kbqRangeLongDateTime` — выводят пустую строку, только если обе границы отсутствуют или некорректны; если задана хотя бы одна граница, они переключаются на формат открытого диапазона. `kbqRangeMiddleDateTime` и pipe продолжительности требуют обе границы, поэтому выводят пустую строку, если отсутствует или некорректна хотя бы одна из них; pipe продолжительности, кроме того, выводят пустую строку, если начало позже конца. + +Если нужно узнать об ошибке, а не скрыть её, вызывайте методы `DateFormatter` напрямую — они бросают исключение. #### Нестандартные форматы diff --git a/packages/components/core/formatters/date/formatter.pipe.ts b/packages/components/core/formatters/date/formatter.pipe.ts index 118c744100..b7d7a0c204 100644 --- a/packages/components/core/formatters/date/formatter.pipe.ts +++ b/packages/components/core/formatters/date/formatter.pipe.ts @@ -11,6 +11,13 @@ import { DateFormatter } from './formatter'; * change detection cycle — a `[from, to]` tuple returned from a getter or a `computed()`, a `units` * array built in a method — still hits the cache of the impure pipes below. Array literals written * directly in a template are already memoized by Angular (`ɵɵpureFunction`); this covers the rest. + * + * Compares array elements by reference, like the `a === b` fallback it wraps — it has no notion of two + * dates being "equal". Mutating a date already passed to a pipe in place (`Date#setHours`, a Moment + * instance updated without cloning, …) keeps the same reference, so a `[from, to]` tuple rebuilt around + * that mutated instance still reads as unchanged and the cached, now-stale string is returned. Replace + * pipe inputs instead of mutating them — the same requirement Angular's OnPush change detection already + * places on any object bound to an OnPush view. */ const shallowEqual = (a: unknown, b: unknown): boolean => { if (a === b) return true; @@ -30,8 +37,14 @@ const toValidDate = (adapter: DateAdapter, value: unknown): D | null => { return date != null && adapter.isValid(date) ? date : null; }; -/** A `[from, to]` tuple with both bounds required; `null` when either is missing or invalid. */ -const toClosedRange = (adapter: DateAdapter, [from, to]: D[] | string[]): [D, D] | null => { +/** + * A `[from, to]` tuple with both bounds required; `null` when either is missing or invalid. + * + * Takes the tuple itself as possibly missing, not just its bounds: a pipe input that has not been + * populated yet is `null`/`undefined` rather than `[null, null]`, and destructuring that throws. + */ +const toClosedRange = (adapter: DateAdapter, value: D[] | string[] | null | undefined): [D, D] | null => { + const [from, to] = value ?? []; const startDate = toValidDate(adapter, from); const endDate = toValidDate(adapter, to); @@ -44,7 +57,11 @@ const toClosedRange = (adapter: DateAdapter, [from, to]: D[] | string[]): * The range formatters switch to the opened-range template on their own when a bound is missing, but * throw when both are — and a throwing pipe aborts the rendering of the whole view. */ -const toOpenedRange = (adapter: DateAdapter, [from, to]: D[] | string[]): [D | null, D | null] | null => { +const toOpenedRange = ( + adapter: DateAdapter, + value: D[] | string[] | null | undefined +): [D | null, D | null] | null => { + const [from, to] = value ?? []; const startDate = toValidDate(adapter, from); const endDate = toValidDate(adapter, to); @@ -55,7 +72,7 @@ const toOpenedRange = (adapter: DateAdapter, [from, to]: D[] | string[]): * A `[from, to]` tuple the duration formatters accept: both bounds required and chronologically * ordered. `DateFormatter.duration*` throws on anything else. */ -const toDurationRange = (adapter: DateAdapter, value: D[] | string[]): [D, D] | null => { +const toDurationRange = (adapter: DateAdapter, value: D[] | string[] | null | undefined): [D, D] | null => { const range = toClosedRange(adapter, value); return range && adapter.compareDateTime(range[0], range[1]) <= 0 ? range : null; @@ -74,7 +91,8 @@ export class BaseFormatterPipe { * - a subscription to `KbqLocaleService.changes` that invalidates the cache and * marks the host for check (the same approach the built-in `AsyncPipe` uses); * - caching by `(value, args, localeId)`, so the impure `transform()` only does - * real work when an input or the active locale actually changed. + * real work when an input or the active locale actually changed — see + * `shallowEqual` for how the comparison works and its limits. * * Subclasses implement `format()`, which receives the raw pipe input(s) — a * single value for absolute/relative pipes, or a `[from, to]` tuple for range diff --git a/packages/components/core/formatters/date/formatter.spec.ts b/packages/components/core/formatters/date/formatter.spec.ts index eaf83c6bbf..0b84d1baff 100644 --- a/packages/components/core/formatters/date/formatter.spec.ts +++ b/packages/components/core/formatters/date/formatter.spec.ts @@ -38,6 +38,7 @@ import { RangeShortDateFormatterPipe, RangeShortDateTimeFormatterPipe } from '@koobiq/components/core'; +import { DurationUnit } from '@koobiq/date-adapter'; import { DateTime, DateTimeUnit } from 'luxon'; /** @@ -53,6 +54,9 @@ const refresh = (fixture: ComponentFixture) => { fixture.detectChanges(); }; +/** What the range and duration pipe hosts bind: a `[from, to]` tuple whose bounds may be absent or unparseable. */ +type RangeValue = (DateTime | string | null)[]; + describe('Date formatter', () => { let adapter: LuxonDateAdapter; let formatter: DateFormatter; @@ -2614,7 +2618,7 @@ describe('Date formatter (imports and providing)', () => { changeDetection: ChangeDetectionStrategy.OnPush }) class DurationPipesHostComponent { - readonly range = signal<(DateTime | string | null)[]>([]); + readonly range = signal([]); } // The legacy families share the formatting code with the `kbq*` ones but not the locale reactivity: @@ -2640,7 +2644,7 @@ describe('Date formatter (imports and providing)', () => { changeDetection: ChangeDetectionStrategy.OnPush }) class LegacyDurationPipesHostComponent { - readonly range = signal<(DateTime | string | null)[]>([]); + readonly range = signal([]); } @Component({ @@ -2724,6 +2728,22 @@ describe('Date formatter (imports and providing)', () => { ); }); + // `toDurationRange` accepts equal bounds (`compareDateTime(...) <= 0`) — only a reversed range is + // unformattable, so a zero duration has to render as one instead of falling through to ''. + it('renders a zero duration for equal bounds', () => { + const fixture = TestBed.createComponent(DurationPipesHostComponent); + + fixture.componentInstance.range.set([start, start]); + fixture.detectChanges(); + + Object.entries(durationExpect).forEach(([id, fn]) => + expect(read(fixture, id)).toBe(fn(dateFormatter, start, start)) + ); + // Not the '' of the unformattable-range guard: the bounds do reach the formatter. Asserted on + // `shortest` because `durationShortest` without seconds renders a zero duration as '' itself. + expect(read(fixture, 'shortest')).not.toBe(''); + }); + it('renders the same output through the legacy pure and impure families', () => { const fixture = TestBed.createComponent(LegacyDurationPipesHostComponent); @@ -2801,21 +2821,25 @@ describe('Date formatter (imports and providing)', () => { // `DateFormatter.duration*` throws on all of these; a throwing pipe would abort the whole view. describe('unformattable input', () => { - const cases: [string, (d: DateTime) => (DateTime | string | null)[]][] = [ + // The last two cases are the tuple itself being missing rather than a bound inside it — what a + // not-yet-populated input actually holds. `toDurationRange` used to destructure it and throw. + const cases: [string, (d: DateTime) => RangeValue | null | undefined][] = [ ['an empty tuple', () => []], ['a missing start', (d) => [null, d]], ['a missing end', (d) => [d, null]], ['both bounds missing', () => [null, null]], ['an unparseable bound', (d) => ['not-a-date', d]], - ['a reversed range', (d) => [d.plus({ days: 1 }), d]] + ['a reversed range', (d) => [d.plus({ days: 1 }), d]], + ['the whole value missing', () => null], + ['the whole value undefined', () => undefined] ]; it.each(cases)('renders an empty string for %s', (_, makeRange) => { const fixture = TestBed.createComponent(DurationPipesHostComponent); const legacyFixture = TestBed.createComponent(LegacyDurationPipesHostComponent); - fixture.componentInstance.range.set(makeRange(start)); - legacyFixture.componentInstance.range.set(makeRange(start)); + fixture.componentInstance.range.set(makeRange(start) as RangeValue); + legacyFixture.componentInstance.range.set(makeRange(start) as RangeValue); expect(() => { fixture.detectChanges(); @@ -2858,7 +2882,7 @@ describe('Date formatter (imports and providing)', () => { changeDetection: ChangeDetectionStrategy.OnPush }) class RangeBoundsHostComponent { - readonly range = signal<(DateTime | string | null)[]>([]); + readonly range = signal([]); } let dateFormatter: DateFormatter; @@ -2898,15 +2922,19 @@ describe('Date formatter (imports and providing)', () => { })); // `openedRangeDate` throws when neither bound is a date, which used to abort the rendering of the - // whole host view — reachable from a template as soon as a range form control is left empty. + // whole host view — reachable from a template as soon as a range form control is left empty. The + // last two cases are the tuple itself being missing rather than a bound inside it, which is what an + // input that has not been populated yet actually holds. it.each([ ['an empty tuple', []], ['both bounds missing', [null, null]], - ['both bounds unparseable', ['x', 'y']] - ])('renders an empty string for %s', (_, range) => { + ['both bounds unparseable', ['x', 'y']], + ['the whole value missing', null], + ['the whole value undefined', undefined] + ] as [string, RangeValue | null | undefined][])('renders an empty string for %s', (_, range) => { const fixture = TestBed.createComponent(RangeBoundsHostComponent); - fixture.componentInstance.range.set(range as (DateTime | string | null)[]); + fixture.componentInstance.range.set(range as RangeValue); expect(() => fixture.detectChanges()).not.toThrow(); @@ -2924,6 +2952,11 @@ describe('Date formatter (imports and providing)', () => { expect(read(fixture, 'kbqShort')).toBe(dateFormatter.rangeShortDate(date)); expect(read(fixture, 'pureLong')).toBe(dateFormatter.rangeLongDate(date, null)); expect(read(fixture, 'pureShort')).toBe(dateFormatter.rangeShortDate(date)); + // `rangeLongDateTime` types its end bound as `D`, not `D | null`, so the pipes pass `undefined`. + expect(read(fixture, 'kbqLongTime')).toBe(dateFormatter.rangeLongDateTime(date)); + expect(read(fixture, 'kbqShortTime')).toBe(dateFormatter.rangeShortDateTime(date, null)); + expect(read(fixture, 'pureLongTime')).toBe(dateFormatter.rangeLongDateTime(date)); + expect(read(fixture, 'pureShortTime')).toBe(dateFormatter.rangeShortDateTime(date, null)); // The middle format has no opened-range template, so it renders nothing instead of throwing. expect(read(fixture, 'kbqMidTime')).toBe(''); @@ -2949,13 +2982,36 @@ describe('Date formatter (imports and providing)', () => { } } + // The same for the arguments rather than the value: the `[from, to]` literal stays memoized while + // `units` is rebuilt on every access, so only `argsEqual` decides whether the cache is hit. + @Component({ + selector: 'kbq-rebuilt-units-host', + imports: [KbqDurationLongPipe], + template: '{{ [from, to] | kbqDurationLong: units }}', + changeDetection: ChangeDetectionStrategy.OnPush + }) + class RebuiltUnitsHostComponent { + from!: DateTime; + to!: DateTime; + unitList: DurationUnit[] = ['hours', 'minutes']; + + get units(): DurationUnit[] { + return [...this.unitList]; + } + } + let dateFormatter: DateFormatter; let testAdapter: LuxonDateAdapter; beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [RebuiltRangeHostComponent, KbqFormattersModule, KbqLuxonDateModule], + imports: [ + RebuiltRangeHostComponent, + RebuiltUnitsHostComponent, + KbqFormattersModule, + KbqLuxonDateModule + ], providers: [ { provide: KBQ_LOCALE_ID, useValue: 'ru-RU' }, { provide: KBQ_LOCALE_DATA, useValue: KBQ_DEFAULT_LOCALE_DATA_FACTORY() }, @@ -2999,5 +3055,37 @@ describe('Date formatter (imports and providing)', () => { expect(fixture.nativeElement.textContent.trim()).not.toBe(rendered); }); + + it('hits the cache for a units array rebuilt on every CD tick', () => { + const fixture = TestBed.createComponent(RebuiltUnitsHostComponent); + + fixture.componentInstance.from = testAdapter.createDateTime(2024, 0, 15, 10, 0, 0, 0); + fixture.componentInstance.to = fixture.componentInstance.from.plus({ days: 2, hours: 4 }); + fixture.detectChanges(); + + const spy = jest.spyOn(dateFormatter, 'durationLong'); + + refresh(fixture); + refresh(fixture); + refresh(fixture); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('recomputes when an element of the rebuilt units array changes', () => { + const fixture = TestBed.createComponent(RebuiltUnitsHostComponent); + + fixture.componentInstance.from = testAdapter.createDateTime(2024, 0, 15, 10, 0, 0, 0); + fixture.componentInstance.to = fixture.componentInstance.from.plus({ days: 2, hours: 4 }); + fixture.detectChanges(); + + const rendered = fixture.nativeElement.textContent.trim(); + + // Same length as the initial units, so only an element-wise comparison can tell them apart. + fixture.componentInstance.unitList = ['days', 'hours']; + refresh(fixture); + + expect(fixture.nativeElement.textContent.trim()).not.toBe(rendered); + }); }); }); From 6f6bc7df3353f30f73a37d03e5e2c7129da0b44f Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 5 Aug 2026 15:44:12 +0300 Subject: [PATCH 3/3] fix(core): accept a not-yet-populated value in date pipes (#DS-4652) Copilot review: the range and duration helpers were widened to `null | undefined` in the previous commit, but the pipes' own signatures still demanded `D[] | string[]`, so strict template type-checking rejected a range that has not been populated yet even though the runtime handled it. Widens every date pipe input to include `null | undefined`, single-value ones as well -- a `date | null` field is at least as common as an empty range, and the docs already promised an empty string for it. Testing that promise turned up the same defect the range guards fixed, still live in the single-value pipes: `deserialize()` answers an unparseable string with a truthy *invalid* date, which passes the `date ? ... : ''` check and then makes the formatter throw "Cannot format invalid date", aborting the rendering of the whole view. They now go through `toValidDate` like the range pipes do. Also drops the `as RangeValue` casts in the spec by widening `RangeValue` itself, which is what the second review comment asked for -- the casts were hiding exactly the mismatch above. --- .../core/formatters/date/date-formatter.en.md | 2 +- .../core/formatters/date/date-formatter.ru.md | 2 +- .../core/formatters/date/formatter.pipe.ts | 211 +++++++++--------- .../core/formatters/date/formatter.spec.ts | 145 ++++++++++-- tools/public_api_guard/components/core.api.md | 168 +++++++------- 5 files changed, 328 insertions(+), 200 deletions(-) diff --git a/packages/components/core/formatters/date/date-formatter.en.md b/packages/components/core/formatters/date/date-formatter.en.md index 6fb00e0ade..ff20931b43 100644 --- a/packages/components/core/formatters/date/date-formatter.en.md +++ b/packages/components/core/formatters/date/date-formatter.en.md @@ -72,7 +72,7 @@ Pass `null` instead of one of the bounds and the range pipe switches to the open #### Empty and invalid values -When a date is missing or cannot be parsed, the pipe renders an empty string. +When a date is missing or cannot be parsed, the pipe renders an empty string. The input types allow this: a binding that has not been populated yet is `null` or `undefined`, not a date, and for the range and duration pipes that applies to the whole `[from, to]` tuple as well as to a bound inside it. The range pipes that support an opened range — `kbqRangeShortDate`, `kbqRangeLongDate`, `kbqRangeShortDateTime`, `kbqRangeLongDateTime` — render an empty string only when both bounds are missing or invalid; a single valid bound switches them to the opened-range format instead. `kbqRangeMiddleDateTime` and the duration pipes need both bounds, so they render an empty string as soon as either one is missing or invalid; the duration pipes additionally render an empty string when the start is later than the end. diff --git a/packages/components/core/formatters/date/date-formatter.ru.md b/packages/components/core/formatters/date/date-formatter.ru.md index a64cee46ea..c1db10e8aa 100644 --- a/packages/components/core/formatters/date/date-formatter.ru.md +++ b/packages/components/core/formatters/date/date-formatter.ru.md @@ -72,7 +72,7 @@ const formattedStringOfDate = this.formatter.absoluteLongDate(this.adapter.today #### Пустые и некорректные значения -Если дату не удалось разобрать или она отсутствует, pipe выводит пустую строку. +Если дату не удалось разобрать или она отсутствует, pipe выводит пустую строку. Это отражено в типах: ещё не заполненная привязка — это `null` или `undefined`, а не дата, и для pipe диапазонов и продолжительности это относится как к самому кортежу `[от, до]`, так и к отдельной границе в нём. Pipe диапазонов, поддерживающие открытый диапазон, — `kbqRangeShortDate`, `kbqRangeLongDate`, `kbqRangeShortDateTime`, `kbqRangeLongDateTime` — выводят пустую строку, только если обе границы отсутствуют или некорректны; если задана хотя бы одна граница, они переключаются на формат открытого диапазона. `kbqRangeMiddleDateTime` и pipe продолжительности требуют обе границы, поэтому выводят пустую строку, если отсутствует или некорректна хотя бы одна из них; pipe продолжительности, кроме того, выводят пустую строку, если начало позже конца. diff --git a/packages/components/core/formatters/date/formatter.pipe.ts b/packages/components/core/formatters/date/formatter.pipe.ts index b7d7a0c204..2fd91e8806 100644 --- a/packages/components/core/formatters/date/formatter.pipe.ts +++ b/packages/components/core/formatters/date/formatter.pipe.ts @@ -103,7 +103,7 @@ export class BaseFormatterPipe { */ export abstract class BaseLocaleAwareFormatterPipe< D, - Value = D | string, + Value = D | string | null | undefined, Args extends unknown[] = unknown[] > extends BaseFormatterPipe { private readonly changeDetectorRef = inject(ChangeDetectorRef); @@ -162,8 +162,8 @@ export abstract class BaseLocaleAwareFormatterPipe< name: 'absoluteLongDate' }) export class AbsoluteDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D | string, currYear?: boolean): string { - const date = this.adapter.deserialize(value); + transform(value: D | string | null | undefined, currYear?: boolean): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.absoluteLongDate(date, currYear) : ''; } @@ -175,7 +175,7 @@ export class AbsoluteDateFormatterPipe extends BaseFormatterPipe implement }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class AbsoluteDateFormatterImpurePipe extends AbsoluteDateFormatterPipe { - transform(value: string | D, currYear?: boolean): string { + transform(value: D | string | null | undefined, currYear?: boolean): string { return super.transform(value, currYear); } } @@ -184,8 +184,8 @@ export class AbsoluteDateFormatterImpurePipe extends AbsoluteDateFormatterPip name: 'absoluteLongDateTime' }) export class AbsoluteDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D | string, options?: DateTimeOptions): string { - const date = this.adapter.deserialize(value); + transform(value: D | string | null | undefined, options?: DateTimeOptions): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.absoluteLongDateTime(date, options) : ''; } @@ -197,7 +197,7 @@ export class AbsoluteDateTimeFormatterPipe extends BaseFormatterPipe imple }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class AbsoluteDateTimeFormatterImpurePipe extends AbsoluteDateTimeFormatterPipe { - transform(value: string | D, options?: DateTimeOptions): string { + transform(value: D | string | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } } @@ -206,8 +206,8 @@ export class AbsoluteDateTimeFormatterImpurePipe extends AbsoluteDateTimeForm name: 'absoluteShortDate' }) export class AbsoluteDateShortFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D | string, currYear?: boolean): string { - const date = this.adapter.deserialize(value); + transform(value: D | string | null | undefined, currYear?: boolean): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.absoluteShortDate(date, currYear) : ''; } @@ -219,7 +219,7 @@ export class AbsoluteDateShortFormatterPipe extends BaseFormatterPipe impl }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class AbsoluteDateShortFormatterImpurePipe extends AbsoluteDateShortFormatterPipe { - transform(value: string | D, currYear?: boolean): string { + transform(value: D | string | null | undefined, currYear?: boolean): string { return super.transform(value, currYear); } } @@ -228,8 +228,8 @@ export class AbsoluteDateShortFormatterImpurePipe extends AbsoluteDateShortFo name: 'absoluteShortDateTime' }) export class AbsoluteShortDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D | string, options?: DateTimeOptions): string { - const date = this.adapter.deserialize(value); + transform(value: D | string | null | undefined, options?: DateTimeOptions): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.absoluteShortDateTime(date, options) : ''; } @@ -241,7 +241,7 @@ export class AbsoluteShortDateTimeFormatterPipe extends BaseFormatterPipe }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class AbsoluteShortDateTimeFormatterImpurePipe extends AbsoluteShortDateTimeFormatterPipe { - transform(value: string | D, options?: DateTimeOptions): string { + transform(value: D | string | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } } @@ -250,8 +250,8 @@ export class AbsoluteShortDateTimeFormatterImpurePipe extends AbsoluteShortDa name: 'relativeLongDate' }) export class RelativeDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D | string): string { - const date = this.adapter.deserialize(value); + transform(value: D | string | null | undefined): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.relativeLongDate(date) : ''; } @@ -263,7 +263,7 @@ export class RelativeDateFormatterPipe extends BaseFormatterPipe implement }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RelativeDateFormatterImpurePipe extends RelativeDateFormatterPipe { - transform(value: string | D): string { + transform(value: D | string | null | undefined): string { return super.transform(value); } } @@ -272,8 +272,8 @@ export class RelativeDateFormatterImpurePipe extends RelativeDateFormatterPip name: 'relativeLongDateTime' }) export class RelativeDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D | string, options?: DateTimeOptions): string { - const date = this.adapter.deserialize(value); + transform(value: D | string | null | undefined, options?: DateTimeOptions): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.relativeLongDateTime(date, options) : ''; } @@ -285,7 +285,7 @@ export class RelativeDateTimeFormatterPipe extends BaseFormatterPipe imple }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RelativeDateTimeFormatterImpurePipe extends RelativeDateTimeFormatterPipe { - transform(value: string | D, options?: DateTimeOptions): string { + transform(value: D | string | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } } @@ -294,8 +294,8 @@ export class RelativeDateTimeFormatterImpurePipe extends RelativeDateTimeForm name: 'relativeShortDate' }) export class RelativeShortDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D | string): string { - const date = this.adapter.deserialize(value); + transform(value: D | string | null | undefined): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.relativeShortDate(date) : ''; } @@ -307,7 +307,7 @@ export class RelativeShortDateFormatterPipe extends BaseFormatterPipe impl }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RelativeShortDateFormatterImpurePipe extends RelativeShortDateFormatterPipe { - transform(value: string | D): string { + transform(value: D | string | null | undefined): string { return super.transform(value); } } @@ -316,8 +316,8 @@ export class RelativeShortDateFormatterImpurePipe extends RelativeShortDateFo name: 'relativeShortDateTime' }) export class RelativeShortDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D | string, options?: DateTimeOptions): string { - const date = this.adapter.deserialize(value); + transform(value: D | string | null | undefined, options?: DateTimeOptions): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.relativeShortDateTime(date, options) : ''; } @@ -329,7 +329,7 @@ export class RelativeShortDateTimeFormatterPipe extends BaseFormatterPipe }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RelativeShortDateTimeFormatterImpurePipe extends RelativeShortDateTimeFormatterPipe { - transform(value: string | D, options?: DateTimeOptions): string { + transform(value: D | string | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } } @@ -338,7 +338,7 @@ export class RelativeShortDateTimeFormatterImpurePipe extends RelativeShortDa name: 'rangeLongDate' }) export class RangeDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D[] | string[]): string { + transform(value: D[] | string[] | null | undefined): string { const range = toOpenedRange(this.adapter, value); if (!range) return ''; @@ -353,7 +353,7 @@ export class RangeDateFormatterPipe extends BaseFormatterPipe implements P }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeDateFormatterImpurePipe extends RangeDateFormatterPipe { - transform(value: D[] | string[]): string { + transform(value: D[] | string[] | null | undefined): string { return super.transform(value); } } @@ -362,7 +362,7 @@ export class RangeDateFormatterImpurePipe extends RangeDateFormatterPipe { name: 'rangeShortDate' }) export class RangeShortDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D[] | string[]): string { + transform(value: D[] | string[] | null | undefined): string { const range = toOpenedRange(this.adapter, value); if (!range) return ''; @@ -377,7 +377,7 @@ export class RangeShortDateFormatterPipe extends BaseFormatterPipe impleme }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeShortDateFormatterImpurePipe extends RangeShortDateFormatterPipe { - transform(value: D[] | string[]): string { + transform(value: D[] | string[] | null | undefined): string { return super.transform(value); } } @@ -386,7 +386,7 @@ export class RangeShortDateFormatterImpurePipe extends RangeShortDateFormatte name: 'rangeLongDateTime' }) export class RangeDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D[] | string[], options?: DateTimeOptions): string { + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { const range = toOpenedRange(this.adapter, value); if (!range) return ''; @@ -401,7 +401,7 @@ export class RangeDateTimeFormatterPipe extends BaseFormatterPipe implemen }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeDateTimeFormatterImpurePipe extends RangeDateTimeFormatterPipe { - transform(value: D[] | string[], options?: DateTimeOptions): string { + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } } @@ -410,7 +410,7 @@ export class RangeDateTimeFormatterImpurePipe extends RangeDateTimeFormatterP name: 'rangeMiddleDateTime' }) export class RangeMiddleDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D[] | string[], options?: DateTimeOptions): string { + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { // Unlike the other range formats, the middle one has no opened-range template — both bounds required. const range = toClosedRange(this.adapter, value); @@ -426,7 +426,7 @@ export class RangeMiddleDateTimeFormatterPipe extends BaseFormatterPipe im }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeMiddleDateTimeFormatterImpurePipe extends RangeMiddleDateTimeFormatterPipe { - transform(value: D[] | string[], options?: DateTimeOptions): string { + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } } @@ -435,7 +435,7 @@ export class RangeMiddleDateTimeFormatterImpurePipe extends RangeMiddleDateTi name: 'rangeShortDateTime' }) export class RangeShortDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D[] | string[], options?: DateTimeOptions): string { + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { const range = toOpenedRange(this.adapter, value); if (!range) return ''; @@ -450,7 +450,7 @@ export class RangeShortDateTimeFormatterPipe extends BaseFormatterPipe imp }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class RangeShortDateTimeFormatterImpurePipe extends RangeShortDateTimeFormatterPipe { - transform(value: D[] | string[], options?: DateTimeOptions): string { + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } } @@ -459,7 +459,7 @@ export class RangeShortDateTimeFormatterImpurePipe extends RangeShortDateTime name: 'durationShortest' }) export class DurationShortestFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D[] | string[], options?: DateTimeOptions): string { + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { const range = toDurationRange(this.adapter, value); if (!range) return ''; @@ -474,7 +474,7 @@ export class DurationShortestFormatterPipe extends BaseFormatterPipe imple }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class DurationShortestFormatterImpurePipe extends DurationShortestFormatterPipe { - transform(value: D[] | string[], options?: DateTimeOptions): string { + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } } @@ -483,7 +483,7 @@ export class DurationShortestFormatterImpurePipe extends DurationShortestForm name: 'durationLong' }) export class DurationLongFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { const range = toDurationRange(this.adapter, value); if (!range) return ''; @@ -498,7 +498,7 @@ export class DurationLongFormatterPipe extends BaseFormatterPipe implement }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class DurationLongFormatterImpurePipe extends DurationLongFormatterPipe { - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { return super.transform(value, units, fraction); } } @@ -507,7 +507,7 @@ export class DurationLongFormatterImpurePipe extends DurationLongFormatterPip name: 'durationShort' }) export class DurationShortFormatterPipe extends BaseFormatterPipe implements PipeTransform { - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { const range = toDurationRange(this.adapter, value); if (!range) return ''; @@ -522,7 +522,7 @@ export class DurationShortFormatterPipe extends BaseFormatterPipe implemen }) // eslint-disable-next-line @angular-eslint/use-pipe-transform-interface export class DurationShortFormatterImpurePipe extends DurationShortFormatterPipe { - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { return super.transform(value, units, fraction); } } @@ -536,15 +536,15 @@ export class DurationShortFormatterImpurePipe extends DurationShortFormatterP pure: false }) export class KbqAbsoluteLongDatePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D | string, currYear?: boolean): string { + override transform(value: D | string | null | undefined, currYear?: boolean): string { return super.transform(value, currYear); } - protected format(value: D | string, currYear?: boolean): string { - const date = this.adapter.deserialize(value); + protected format(value: D | string | null | undefined, currYear?: boolean): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.absoluteLongDate(date, currYear) : ''; } @@ -555,15 +555,15 @@ export class KbqAbsoluteLongDatePipe pure: false }) export class KbqAbsoluteShortDatePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D | string, currYear?: boolean): string { + override transform(value: D | string | null | undefined, currYear?: boolean): string { return super.transform(value, currYear); } - protected format(value: D | string, currYear?: boolean): string { - const date = this.adapter.deserialize(value); + protected format(value: D | string | null | undefined, currYear?: boolean): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.absoluteShortDate(date, currYear) : ''; } @@ -574,15 +574,15 @@ export class KbqAbsoluteShortDatePipe pure: false }) export class KbqAbsoluteLongDateTimePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D | string, options?: DateTimeOptions): string { + override transform(value: D | string | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } - protected format(value: D | string, options?: DateTimeOptions): string { - const date = this.adapter.deserialize(value); + protected format(value: D | string | null | undefined, options?: DateTimeOptions): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.absoluteLongDateTime(date, options) : ''; } @@ -593,15 +593,15 @@ export class KbqAbsoluteLongDateTimePipe pure: false }) export class KbqAbsoluteShortDateTimePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D | string, options?: DateTimeOptions): string { + override transform(value: D | string | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } - protected format(value: D | string, options?: DateTimeOptions): string { - const date = this.adapter.deserialize(value); + protected format(value: D | string | null | undefined, options?: DateTimeOptions): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.absoluteShortDateTime(date, options) : ''; } @@ -612,15 +612,15 @@ export class KbqAbsoluteShortDateTimePipe pure: false }) export class KbqRelativeLongDatePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D | string): string { + override transform(value: D | string | null | undefined): string { return super.transform(value); } - protected format(value: D | string): string { - const date = this.adapter.deserialize(value); + protected format(value: D | string | null | undefined): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.relativeLongDate(date) : ''; } @@ -631,15 +631,15 @@ export class KbqRelativeLongDatePipe pure: false }) export class KbqRelativeShortDatePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D | string): string { + override transform(value: D | string | null | undefined): string { return super.transform(value); } - protected format(value: D | string): string { - const date = this.adapter.deserialize(value); + protected format(value: D | string | null | undefined): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.relativeShortDate(date) : ''; } @@ -650,15 +650,15 @@ export class KbqRelativeShortDatePipe pure: false }) export class KbqRelativeLongDateTimePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D | string, options?: DateTimeOptions): string { + override transform(value: D | string | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } - protected format(value: D | string, options?: DateTimeOptions): string { - const date = this.adapter.deserialize(value); + protected format(value: D | string | null | undefined, options?: DateTimeOptions): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.relativeLongDateTime(date, options) : ''; } @@ -669,15 +669,15 @@ export class KbqRelativeLongDateTimePipe pure: false }) export class KbqRelativeShortDateTimePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D | string, options?: DateTimeOptions): string { + override transform(value: D | string | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } - protected format(value: D | string, options?: DateTimeOptions): string { - const date = this.adapter.deserialize(value); + protected format(value: D | string | null | undefined, options?: DateTimeOptions): string { + const date = toValidDate(this.adapter, value); return date ? this.formatter.relativeShortDateTime(date, options) : ''; } @@ -688,14 +688,14 @@ export class KbqRelativeShortDateTimePipe pure: false }) export class KbqRangeLongDatePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D[] | string[]): string { + override transform(value: D[] | string[] | null | undefined): string { return super.transform(value); } - protected format(value: D[] | string[]): string { + protected format(value: D[] | string[] | null | undefined): string { const range = toOpenedRange(this.adapter, value); if (!range) return ''; @@ -709,14 +709,14 @@ export class KbqRangeLongDatePipe pure: false }) export class KbqRangeShortDatePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D[] | string[]): string { + override transform(value: D[] | string[] | null | undefined): string { return super.transform(value); } - protected format(value: D[] | string[]): string { + protected format(value: D[] | string[] | null | undefined): string { const range = toOpenedRange(this.adapter, value); if (!range) return ''; @@ -730,14 +730,14 @@ export class KbqRangeShortDatePipe pure: false }) export class KbqRangeLongDateTimePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D[] | string[], options?: DateTimeOptions): string { + override transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } - protected format(value: D[] | string[], options?: DateTimeOptions): string { + protected format(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { const range = toOpenedRange(this.adapter, value); if (!range) return ''; @@ -751,14 +751,14 @@ export class KbqRangeLongDateTimePipe pure: false }) export class KbqRangeMiddleDateTimePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D[] | string[], options?: DateTimeOptions): string { + override transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } - protected format(value: D[] | string[], options?: DateTimeOptions): string { + protected format(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { // Unlike the other range formats, the middle one has no opened-range template — both bounds required. const range = toClosedRange(this.adapter, value); @@ -773,14 +773,14 @@ export class KbqRangeMiddleDateTimePipe pure: false }) export class KbqRangeShortDateTimePipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D[] | string[], options?: DateTimeOptions): string { + override transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } - protected format(value: D[] | string[], options?: DateTimeOptions): string { + protected format(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { const range = toOpenedRange(this.adapter, value); if (!range) return ''; @@ -795,7 +795,8 @@ export class KbqRangeShortDateTimePipe * Takes a `[from, to]` tuple, like the range pipes. `options.seconds` defaults to `true` and * `options.milliseconds` to `false`; `options.currYear` is not used by this format. * - * Renders an empty string when a bound is missing or invalid, or when `from` is later than `to`. + * Renders an empty string when the tuple itself or one of its bounds is missing or invalid, or when + * `from` is later than `to`. * * @example * ```html @@ -808,14 +809,14 @@ export class KbqRangeShortDateTimePipe pure: false }) export class KbqDurationShortestPipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe implements PipeTransform { - override transform(value: D[] | string[], options?: DateTimeOptions): string { + override transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { return super.transform(value, options); } - protected format(value: D[] | string[], options?: DateTimeOptions): string { + protected format(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string { const range = toDurationRange(this.adapter, value); if (!range) return ''; @@ -831,7 +832,8 @@ export class KbqDurationShortestPipe * formatter picks them automatically when omitted), `fraction` adds a fractional part for years * and months. * - * Renders an empty string when a bound is missing or invalid, or when `from` is later than `to`. + * Renders an empty string when the tuple itself or one of its bounds is missing or invalid, or when + * `from` is later than `to`. * * @example * ```html @@ -845,14 +847,18 @@ export class KbqDurationShortestPipe pure: false }) export class KbqDurationLongPipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe< + D, + D[] | string[] | null | undefined, + [units?: DurationUnit[], fraction?: boolean] + > implements PipeTransform { - override transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + override transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { return super.transform(value, units, fraction); } - protected format(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + protected format(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { const range = toDurationRange(this.adapter, value); if (!range) return ''; @@ -868,7 +874,8 @@ export class KbqDurationLongPipe * formatter picks them automatically when omitted), `fraction` adds a fractional part for years * and months. * - * Renders an empty string when a bound is missing or invalid, or when `from` is later than `to`. + * Renders an empty string when the tuple itself or one of its bounds is missing or invalid, or when + * `from` is later than `to`. * * @example * ```html @@ -881,14 +888,18 @@ export class KbqDurationLongPipe pure: false }) export class KbqDurationShortPipe - extends BaseLocaleAwareFormatterPipe + extends BaseLocaleAwareFormatterPipe< + D, + D[] | string[] | null | undefined, + [units?: DurationUnit[], fraction?: boolean] + > implements PipeTransform { - override transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + override transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { return super.transform(value, units, fraction); } - protected format(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string { + protected format(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { const range = toDurationRange(this.adapter, value); if (!range) return ''; diff --git a/packages/components/core/formatters/date/formatter.spec.ts b/packages/components/core/formatters/date/formatter.spec.ts index 0b84d1baff..962f400307 100644 --- a/packages/components/core/formatters/date/formatter.spec.ts +++ b/packages/components/core/formatters/date/formatter.spec.ts @@ -2,6 +2,10 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, LOCALE_ID, signa import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { KbqLuxonDateModule, LuxonDateAdapter, LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { + AbsoluteDateFormatterPipe, + AbsoluteDateShortFormatterPipe, + AbsoluteDateTimeFormatterPipe, + AbsoluteShortDateTimeFormatterPipe, DateAdapter, DateFormatter, DurationLongFormatterImpurePipe, @@ -36,7 +40,11 @@ import { RangeDateTimeFormatterPipe, RangeMiddleDateTimeFormatterPipe, RangeShortDateFormatterPipe, - RangeShortDateTimeFormatterPipe + RangeShortDateTimeFormatterPipe, + RelativeDateFormatterPipe, + RelativeDateTimeFormatterPipe, + RelativeShortDateFormatterPipe, + RelativeShortDateTimeFormatterPipe } from '@koobiq/components/core'; import { DurationUnit } from '@koobiq/date-adapter'; import { DateTime, DateTimeUnit } from 'luxon'; @@ -54,8 +62,11 @@ const refresh = (fixture: ComponentFixture) => { fixture.detectChanges(); }; -/** What the range and duration pipe hosts bind: a `[from, to]` tuple whose bounds may be absent or unparseable. */ -type RangeValue = (DateTime | string | null)[]; +/** + * What the range and duration pipe hosts bind, matching what the pipes accept: a `[from, to]` tuple whose + * bounds may be absent or unparseable, or no tuple at all for an input that has not been populated yet. + */ +type RangeValue = (DateTime | string | null)[] | null | undefined; describe('Date formatter', () => { let adapter: LuxonDateAdapter; @@ -2524,8 +2535,8 @@ describe('Date formatter (imports and providing)', () => { changeDetection: ChangeDetectionStrategy.OnPush }) class AllPipesHostComponent { - readonly value = signal(null); - readonly range = signal([]); + readonly value = signal(null); + readonly range = signal([]); } let localeService: KbqLocaleService; @@ -2572,6 +2583,9 @@ describe('Date formatter (imports and providing)', () => { } )); + const read = (fixture: ComponentFixture, id: string): string => + fixture.nativeElement.querySelector(`#${id}`).textContent.trim(); + it('renders every format and recomputes all of them on locale change', () => { const fixture = TestBed.createComponent(AllPipesHostComponent); const d1 = testAdapter.createDate(2024, 0, 15); @@ -2580,24 +2594,43 @@ describe('Date formatter (imports and providing)', () => { fixture.componentInstance.value.set(d1); fixture.componentInstance.range.set([d1, d2]); - const read = (id: string): string => fixture.nativeElement.querySelector(`#${id}`).textContent.trim(); - // `dateFormatter` is the same instance the pipes use; it tracks the active locale itself. const assertMatchesActiveLocale = () => { - Object.entries(singleExpect).forEach(([id, fn]) => expect(read(id)).toBe(fn(dateFormatter, d1))); - Object.entries(rangeExpect).forEach(([id, fn]) => expect(read(id)).toBe(fn(dateFormatter, d1, d2))); + Object.entries(singleExpect).forEach(([id, fn]) => + expect(read(fixture, id)).toBe(fn(dateFormatter, d1)) + ); + Object.entries(rangeExpect).forEach(([id, fn]) => + expect(read(fixture, id)).toBe(fn(dateFormatter, d1, d2)) + ); }; fixture.detectChanges(); assertMatchesActiveLocale(); - const ruAbsLong = read('absLong'); + const ruAbsLong = read(fixture, 'absLong'); localeService.setLocale('en-US'); fixture.detectChanges(); assertMatchesActiveLocale(); - expect(read('absLong')).not.toBe(ruAbsLong); + expect(read(fixture, 'absLong')).not.toBe(ruAbsLong); + }); + + // The single-value pipes take a not-yet-populated input too, not just the tuple ones. + const emptyValues: [string, DateTime | string | null | undefined][] = [ + ['null', null], + ['undefined', undefined], + ['an unparseable string', 'not-a-date'] + ]; + + it.each(emptyValues)('renders an empty string for a value that is %s', (_, value) => { + const fixture = TestBed.createComponent(AllPipesHostComponent); + + fixture.componentInstance.value.set(value); + + expect(() => fixture.detectChanges()).not.toThrow(); + + Object.keys(singleExpect).forEach((id) => expect(read(fixture, id)).toBe('')); }); }); @@ -2823,7 +2856,7 @@ describe('Date formatter (imports and providing)', () => { describe('unformattable input', () => { // The last two cases are the tuple itself being missing rather than a bound inside it — what a // not-yet-populated input actually holds. `toDurationRange` used to destructure it and throw. - const cases: [string, (d: DateTime) => RangeValue | null | undefined][] = [ + const cases: [string, (d: DateTime) => RangeValue][] = [ ['an empty tuple', () => []], ['a missing start', (d) => [null, d]], ['a missing end', (d) => [d, null]], @@ -2838,8 +2871,8 @@ describe('Date formatter (imports and providing)', () => { const fixture = TestBed.createComponent(DurationPipesHostComponent); const legacyFixture = TestBed.createComponent(LegacyDurationPipesHostComponent); - fixture.componentInstance.range.set(makeRange(start) as RangeValue); - legacyFixture.componentInstance.range.set(makeRange(start) as RangeValue); + fixture.componentInstance.range.set(makeRange(start)); + legacyFixture.componentInstance.range.set(makeRange(start)); expect(() => { fixture.detectChanges(); @@ -2925,16 +2958,18 @@ describe('Date formatter (imports and providing)', () => { // whole host view — reachable from a template as soon as a range form control is left empty. The // last two cases are the tuple itself being missing rather than a bound inside it, which is what an // input that has not been populated yet actually holds. - it.each([ + const emptyCases: [string, RangeValue][] = [ ['an empty tuple', []], ['both bounds missing', [null, null]], ['both bounds unparseable', ['x', 'y']], ['the whole value missing', null], ['the whole value undefined', undefined] - ] as [string, RangeValue | null | undefined][])('renders an empty string for %s', (_, range) => { + ]; + + it.each(emptyCases)('renders an empty string for %s', (_, range) => { const fixture = TestBed.createComponent(RangeBoundsHostComponent); - fixture.componentInstance.range.set(range as RangeValue); + fixture.componentInstance.range.set(range); expect(() => fixture.detectChanges()).not.toThrow(); @@ -2964,6 +2999,82 @@ describe('Date formatter (imports and providing)', () => { }); }); + describe('Legacy single-value date pipes with missing values', () => { + @Component({ + selector: 'kbq-legacy-value-host', + imports: [ + AbsoluteDateFormatterPipe, + AbsoluteDateShortFormatterPipe, + AbsoluteDateTimeFormatterPipe, + AbsoluteShortDateTimeFormatterPipe, + RelativeDateFormatterPipe, + RelativeDateTimeFormatterPipe, + RelativeShortDateFormatterPipe, + RelativeShortDateTimeFormatterPipe + ], + template: ` + {{ value() | absoluteLongDate }} + {{ value() | absoluteLongDateTime }} + {{ value() | absoluteShortDate }} + {{ value() | absoluteShortDateTime }} + {{ value() | relativeLongDate }} + {{ value() | relativeLongDateTime }} + {{ value() | relativeShortDate }} + {{ value() | relativeShortDateTime }} + `, + changeDetection: ChangeDetectionStrategy.OnPush + }) + class LegacyValueHostComponent { + readonly value = signal(null); + } + + const allIds = [ + 'absLong', + 'absLongTime', + 'absShort', + 'absShortTime', + 'relLong', + 'relLongTime', + 'relShort', + 'relShortTime' + ]; + + const read = (fixture: ComponentFixture, id: string): string => + fixture.nativeElement.querySelector(`#${id}`).textContent.trim(); + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [LegacyValueHostComponent, KbqFormattersModule, KbqLuxonDateModule], + providers: [ + { provide: KBQ_LOCALE_ID, useValue: 'ru-RU' }, + { provide: KBQ_LOCALE_DATA, useValue: KBQ_DEFAULT_LOCALE_DATA_FACTORY() }, + { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService } + ] + }); + }); + + // The `kbq*` family is covered above; these share the guard through `toValidDate`. An unparseable + // string is the interesting case: `deserialize` answers it with a truthy *invalid* date, which the + // formatter then refuses to format, so a plain truthiness check used to let it throw. + const emptyValues: [string, DateTime | string | null | undefined][] = [ + ['null', null], + ['undefined', undefined], + ['an empty string', ''], + ['an unparseable string', 'not-a-date'] + ]; + + it.each(emptyValues)('renders an empty string for a value that is %s', (_, value) => { + const fixture = TestBed.createComponent(LegacyValueHostComponent); + + fixture.componentInstance.value.set(value); + + expect(() => fixture.detectChanges()).not.toThrow(); + + allIds.forEach((id) => expect(read(fixture, id)).toBe('')); + }); + }); + describe('BaseLocaleAwareFormatterPipe caching', () => { // A getter rebuilds the tuple on every change detection cycle; an array literal written directly in // a template does not, because Angular memoizes it with `ɵɵpureFunction`. diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 23a29d93c1..6143ea8b51 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -71,7 +71,7 @@ export const A = 65; // @public (undocumented) export class AbsoluteDateFormatterImpurePipe extends AbsoluteDateFormatterPipe { // (undocumented) - transform(value: string | D, currYear?: boolean): string; + transform(value: D | string | null | undefined, currYear?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -81,7 +81,7 @@ export class AbsoluteDateFormatterImpurePipe extends AbsoluteDateFormatterPip // @public (undocumented) export class AbsoluteDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D | string, currYear?: boolean): string; + transform(value: D | string | null | undefined, currYear?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -91,7 +91,7 @@ export class AbsoluteDateFormatterPipe extends BaseFormatterPipe implement // @public (undocumented) export class AbsoluteDateShortFormatterImpurePipe extends AbsoluteDateShortFormatterPipe { // (undocumented) - transform(value: string | D, currYear?: boolean): string; + transform(value: D | string | null | undefined, currYear?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -101,7 +101,7 @@ export class AbsoluteDateShortFormatterImpurePipe extends AbsoluteDateShortFo // @public (undocumented) export class AbsoluteDateShortFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D | string, currYear?: boolean): string; + transform(value: D | string | null | undefined, currYear?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -111,7 +111,7 @@ export class AbsoluteDateShortFormatterPipe extends BaseFormatterPipe impl // @public (undocumented) export class AbsoluteDateTimeFormatterImpurePipe extends AbsoluteDateTimeFormatterPipe { // (undocumented) - transform(value: string | D, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -121,7 +121,7 @@ export class AbsoluteDateTimeFormatterImpurePipe extends AbsoluteDateTimeForm // @public (undocumented) export class AbsoluteDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D | string, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -131,7 +131,7 @@ export class AbsoluteDateTimeFormatterPipe extends BaseFormatterPipe imple // @public (undocumented) export class AbsoluteShortDateTimeFormatterImpurePipe extends AbsoluteShortDateTimeFormatterPipe { // (undocumented) - transform(value: string | D, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -141,7 +141,7 @@ export class AbsoluteShortDateTimeFormatterImpurePipe extends AbsoluteShortDa // @public (undocumented) export class AbsoluteShortDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D | string, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -187,7 +187,7 @@ export class BaseFormatterPipe { } // @public -export abstract class BaseLocaleAwareFormatterPipe extends BaseFormatterPipe { +export abstract class BaseLocaleAwareFormatterPipe extends BaseFormatterPipe { constructor(); // (undocumented) protected abstract format(value: Value, ...args: Args): string; @@ -322,7 +322,7 @@ export const DOWN_ARROW = 40; // @public (undocumented) export class DurationLongFormatterImpurePipe extends DurationLongFormatterPipe { // (undocumented) - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -332,7 +332,7 @@ export class DurationLongFormatterImpurePipe extends DurationLongFormatterPip // @public (undocumented) export class DurationLongFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -342,7 +342,7 @@ export class DurationLongFormatterPipe extends BaseFormatterPipe implement // @public (undocumented) export class DurationShortestFormatterImpurePipe extends DurationShortestFormatterPipe { // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -352,7 +352,7 @@ export class DurationShortestFormatterImpurePipe extends DurationShortestForm // @public (undocumented) export class DurationShortestFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -362,7 +362,7 @@ export class DurationShortestFormatterPipe extends BaseFormatterPipe imple // @public (undocumented) export class DurationShortFormatterImpurePipe extends DurationShortFormatterPipe { // (undocumented) - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -372,7 +372,7 @@ export class DurationShortFormatterImpurePipe extends DurationShortFormatterP // @public (undocumented) export class DurationShortFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -2447,11 +2447,11 @@ export type KbqA11yLocaleConfiguration = { export const kbqA11yLocaleConfigurationProvider: (configuration: KbqA11yLocaleConfiguration) => Provider; // @public (undocumented) -export class KbqAbsoluteLongDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqAbsoluteLongDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D | string, currYear?: boolean): string; + protected format(value: D | string | null | undefined, currYear?: boolean): string; // (undocumented) - transform(value: D | string, currYear?: boolean): string; + transform(value: D | string | null | undefined, currYear?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -2459,11 +2459,11 @@ export class KbqAbsoluteLongDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqAbsoluteLongDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D | string, options?: DateTimeOptions): string; + protected format(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) - transform(value: D | string, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -2471,11 +2471,11 @@ export class KbqAbsoluteLongDateTimePipe extends BaseLocaleAwareFormatterPipe } // @public (undocumented) -export class KbqAbsoluteShortDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqAbsoluteShortDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D | string, currYear?: boolean): string; + protected format(value: D | string | null | undefined, currYear?: boolean): string; // (undocumented) - transform(value: D | string, currYear?: boolean): string; + transform(value: D | string | null | undefined, currYear?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -2483,11 +2483,11 @@ export class KbqAbsoluteShortDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqAbsoluteShortDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D | string, options?: DateTimeOptions): string; + protected format(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) - transform(value: D | string, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -2713,11 +2713,14 @@ export type KbqDefaultSizes = 'compact' | 'normal' | 'big'; export const KbqDefaultThemes: KbqTheme[]; // @public -export class KbqDurationLongPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqDurationLongPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + protected format(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; // (undocumented) - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -2725,11 +2728,11 @@ export class KbqDurationLongPipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqDurationShortestPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D[] | string[], options?: DateTimeOptions): string; + protected format(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -2737,11 +2740,14 @@ export class KbqDurationShortestPipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqDurationShortPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + protected format(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; // (undocumented) - transform(value: D[] | string[], units?: DurationUnit[], fraction?: boolean): string; + transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -3483,11 +3489,11 @@ export class KbqPseudoCheckboxModule { export type KbqPseudoCheckboxState = 'unchecked' | 'checked' | 'indeterminate' | boolean; // @public (undocumented) -export class KbqRangeLongDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRangeLongDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D[] | string[]): string; + protected format(value: D[] | string[] | null | undefined): string; // (undocumented) - transform(value: D[] | string[]): string; + transform(value: D[] | string[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -3495,11 +3501,11 @@ export class KbqRangeLongDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRangeLongDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D[] | string[], options?: DateTimeOptions): string; + protected format(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -3507,11 +3513,11 @@ export class KbqRangeLongDateTimePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRangeMiddleDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D[] | string[], options?: DateTimeOptions): string; + protected format(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -3519,11 +3525,11 @@ export class KbqRangeMiddleDateTimePipe extends BaseLocaleAwareFormatterPipe< } // @public (undocumented) -export class KbqRangeShortDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRangeShortDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D[] | string[]): string; + protected format(value: D[] | string[] | null | undefined): string; // (undocumented) - transform(value: D[] | string[]): string; + transform(value: D[] | string[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -3531,11 +3537,11 @@ export class KbqRangeShortDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRangeShortDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D[] | string[], options?: DateTimeOptions): string; + protected format(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -3580,11 +3586,11 @@ export class KbqRectangleItem { } // @public (undocumented) -export class KbqRelativeLongDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRelativeLongDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D | string): string; + protected format(value: D | string | null | undefined): string; // (undocumented) - transform(value: D | string): string; + transform(value: D | string | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -3592,11 +3598,11 @@ export class KbqRelativeLongDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRelativeLongDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D | string, options?: DateTimeOptions): string; + protected format(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) - transform(value: D | string, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -3604,11 +3610,11 @@ export class KbqRelativeLongDateTimePipe extends BaseLocaleAwareFormatterPipe } // @public (undocumented) -export class KbqRelativeShortDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRelativeShortDatePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D | string): string; + protected format(value: D | string | null | undefined): string; // (undocumented) - transform(value: D | string): string; + transform(value: D | string | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -3616,11 +3622,11 @@ export class KbqRelativeShortDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRelativeShortDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(value: D | string, options?: DateTimeOptions): string; + protected format(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) - transform(value: D | string, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4476,7 +4482,7 @@ export const R = 82; // @public (undocumented) export class RangeDateFormatterImpurePipe extends RangeDateFormatterPipe { // (undocumented) - transform(value: D[] | string[]): string; + transform(value: D[] | string[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4486,7 +4492,7 @@ export class RangeDateFormatterImpurePipe extends RangeDateFormatterPipe { // @public (undocumented) export class RangeDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D[] | string[]): string; + transform(value: D[] | string[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4496,7 +4502,7 @@ export class RangeDateFormatterPipe extends BaseFormatterPipe implements P // @public (undocumented) export class RangeDateTimeFormatterImpurePipe extends RangeDateTimeFormatterPipe { // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4506,7 +4512,7 @@ export class RangeDateTimeFormatterImpurePipe extends RangeDateTimeFormatterP // @public (undocumented) export class RangeDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4516,7 +4522,7 @@ export class RangeDateTimeFormatterPipe extends BaseFormatterPipe implemen // @public (undocumented) export class RangeMiddleDateTimeFormatterImpurePipe extends RangeMiddleDateTimeFormatterPipe { // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4526,7 +4532,7 @@ export class RangeMiddleDateTimeFormatterImpurePipe extends RangeMiddleDateTi // @public (undocumented) export class RangeMiddleDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4536,7 +4542,7 @@ export class RangeMiddleDateTimeFormatterPipe extends BaseFormatterPipe im // @public (undocumented) export class RangeShortDateFormatterImpurePipe extends RangeShortDateFormatterPipe { // (undocumented) - transform(value: D[] | string[]): string; + transform(value: D[] | string[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4546,7 +4552,7 @@ export class RangeShortDateFormatterImpurePipe extends RangeShortDateFormatte // @public (undocumented) export class RangeShortDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D[] | string[]): string; + transform(value: D[] | string[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4556,7 +4562,7 @@ export class RangeShortDateFormatterPipe extends BaseFormatterPipe impleme // @public (undocumented) export class RangeShortDateTimeFormatterImpurePipe extends RangeShortDateTimeFormatterPipe { // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4566,7 +4572,7 @@ export class RangeShortDateTimeFormatterImpurePipe extends RangeShortDateTime // @public (undocumented) export class RangeShortDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D[] | string[], options?: DateTimeOptions): string; + transform(value: D[] | string[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4576,7 +4582,7 @@ export class RangeShortDateTimeFormatterPipe extends BaseFormatterPipe imp // @public (undocumented) export class RelativeDateFormatterImpurePipe extends RelativeDateFormatterPipe { // (undocumented) - transform(value: string | D): string; + transform(value: D | string | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4586,7 +4592,7 @@ export class RelativeDateFormatterImpurePipe extends RelativeDateFormatterPip // @public (undocumented) export class RelativeDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D | string): string; + transform(value: D | string | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4596,7 +4602,7 @@ export class RelativeDateFormatterPipe extends BaseFormatterPipe implement // @public (undocumented) export class RelativeDateTimeFormatterImpurePipe extends RelativeDateTimeFormatterPipe { // (undocumented) - transform(value: string | D, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4606,7 +4612,7 @@ export class RelativeDateTimeFormatterImpurePipe extends RelativeDateTimeForm // @public (undocumented) export class RelativeDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D | string, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4616,7 +4622,7 @@ export class RelativeDateTimeFormatterPipe extends BaseFormatterPipe imple // @public (undocumented) export class RelativeShortDateFormatterImpurePipe extends RelativeShortDateFormatterPipe { // (undocumented) - transform(value: string | D): string; + transform(value: D | string | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4626,7 +4632,7 @@ export class RelativeShortDateFormatterImpurePipe extends RelativeShortDateFo // @public (undocumented) export class RelativeShortDateFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D | string): string; + transform(value: D | string | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4636,7 +4642,7 @@ export class RelativeShortDateFormatterPipe extends BaseFormatterPipe impl // @public (undocumented) export class RelativeShortDateTimeFormatterImpurePipe extends RelativeShortDateTimeFormatterPipe { // (undocumented) - transform(value: string | D, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4646,7 +4652,7 @@ export class RelativeShortDateTimeFormatterImpurePipe extends RelativeShortDa // @public (undocumented) export class RelativeShortDateTimeFormatterPipe extends BaseFormatterPipe implements PipeTransform { // (undocumented) - transform(value: D | string, options?: DateTimeOptions): string; + transform(value: D | string | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented)