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..ff20931b43 100644 --- a/packages/components/core/formatters/date/date-formatter.en.md +++ b/packages/components/core/formatters/date/date-formatter.en.md @@ -1,17 +1,96 @@ -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. 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. + +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..c1db10e8aa 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,85 @@ 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 выводит пустую строку. Это отражено в типах: ещё не заполненная привязка — это `null` или `undefined`, а не дата, и для pipe диапазонов и продолжительности это относится как к самому кортежу `[от, до]`, так и к отдельной границе в нём. + +Pipe диапазонов, поддерживающие открытый диапазон, — `kbqRangeShortDate`, `kbqRangeLongDate`, `kbqRangeShortDateTime`, `kbqRangeLongDateTime` — выводят пустую строку, только если обе границы отсутствуют или некорректны; если задана хотя бы одна граница, они переключаются на формат открытого диапазона. `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..2fd91e8806 100644 --- a/packages/components/core/formatters/date/formatter.pipe.ts +++ b/packages/components/core/formatters/date/formatter.pipe.ts @@ -1,10 +1,83 @@ 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. + * + * 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; + 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. + * + * 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); + + 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, + value: D[] | string[] | null | undefined +): [D | null, D | null] | null => { + const [from, to] = value ?? []; + 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[] | null | undefined): [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); @@ -18,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 @@ -29,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); @@ -55,7 +129,7 @@ export abstract class BaseLocaleAwareFormatterPipe< if ( this.hasCache && - value === this.cachedValue && + shallowEqual(value, this.cachedValue) && currentLocaleId === this.cachedLocaleId && this.argsEqual(args) ) { @@ -77,7 +151,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; @@ -88,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) : ''; } @@ -101,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); } } @@ -110,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) : ''; } @@ -123,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); } } @@ -132,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) : ''; } @@ -145,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); } } @@ -154,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) : ''; } @@ -167,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); } } @@ -176,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) : ''; } @@ -189,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); } } @@ -198,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) : ''; } @@ -211,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); } } @@ -220,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) : ''; } @@ -233,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); } } @@ -242,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) : ''; } @@ -255,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); } } @@ -264,11 +338,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[] | null | undefined): 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]); } } @@ -278,8 +353,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[] | null | undefined): string { + return super.transform(value); } } @@ -287,11 +362,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[] | null | undefined): string { + const range = toOpenedRange(this.adapter, value); + + if (!range) return ''; - return this.formatter.rangeShortDate(date1 as D, date2 as D); + return this.formatter.rangeShortDate(range[0], range[1] ?? undefined); } } @@ -301,8 +377,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[] | null | undefined): string { + return super.transform(value); } } @@ -310,11 +386,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[] | null | undefined, 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); } } @@ -324,8 +401,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[] | null | undefined, options?: DateTimeOptions): string { + return super.transform(value, options); } } @@ -333,11 +410,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[] | 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); + + if (!range) return ''; - return this.formatter.rangeMiddleDateTime(date1 as D, date2 as D, options); + return this.formatter.rangeMiddleDateTime(range[0], range[1], options); } } @@ -347,8 +426,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[] | null | undefined, options?: DateTimeOptions): string { + return super.transform(value, options); } } @@ -356,11 +435,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[] | null | undefined, options?: DateTimeOptions): string { + const range = toOpenedRange(this.adapter, value); + + if (!range) return ''; - return this.formatter.rangeShortDateTime(date1 as D, date2 as D, options); + return this.formatter.rangeShortDateTime(range[0], range[1], options); } } @@ -370,8 +450,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[] | null | undefined, options?: DateTimeOptions): string { + return super.transform(value, options); + } +} + +@Pipe({ + name: 'durationShortest' +}) +export class DurationShortestFormatterPipe extends BaseFormatterPipe implements PipeTransform { + transform(value: D[] | string[] | null | undefined, 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[] | null | undefined, options?: DateTimeOptions): string { + return super.transform(value, options); + } +} + +@Pipe({ + name: 'durationLong' +}) +export class DurationLongFormatterPipe extends BaseFormatterPipe implements PipeTransform { + transform(value: D[] | string[] | null | undefined, 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[] | null | undefined, 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[] | null | undefined, 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[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { + return super.transform(value, units, fraction); } } @@ -384,15 +536,15 @@ export class RangeShortDateTimeFormatterImpurePipe extends RangeShortDateTime 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) : ''; } @@ -403,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) : ''; } @@ -422,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) : ''; } @@ -441,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) : ''; } @@ -460,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) : ''; } @@ -479,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) : ''; } @@ -498,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) : ''; } @@ -517,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) : ''; } @@ -536,18 +688,19 @@ 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([value1, value2]: D[] | string[]): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + protected format(value: D[] | string[] | null | undefined): 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]); } } @@ -556,18 +709,19 @@ 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([value1, value2]: D[] | string[]): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + protected format(value: D[] | string[] | null | undefined): string { + const range = toOpenedRange(this.adapter, value); + + if (!range) return ''; - return this.formatter.rangeShortDate(date1 as D, date2 as D); + return this.formatter.rangeShortDate(range[0], range[1] ?? undefined); } } @@ -576,18 +730,19 @@ 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([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + protected format(value: D[] | string[] | null | undefined, 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); } } @@ -596,18 +751,20 @@ 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([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + 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); + + if (!range) return ''; - return this.formatter.rangeMiddleDateTime(date1 as D, date2 as D, options); + return this.formatter.rangeMiddleDateTime(range[0], range[1], options); } } @@ -616,17 +773,137 @@ 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([value1, value2]: D[] | string[], options?: DateTimeOptions): string { - const date1 = this.adapter.deserialize(value1); - const date2 = this.adapter.deserialize(value2); + protected format(value: D[] | string[] | null | undefined, 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 the tuple itself or one of its bounds 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[] | null | undefined, options?: DateTimeOptions): string { + return super.transform(value, options); + } + + protected format(value: D[] | string[] | null | undefined, 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 the tuple itself or one of its bounds 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< + D, + D[] | string[] | null | undefined, + [units?: DurationUnit[], fraction?: boolean] + > + implements PipeTransform +{ + override transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { + return super.transform(value, units, fraction); + } + + protected format(value: D[] | string[] | null | undefined, 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 the tuple itself or one of its bounds 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< + D, + D[] | string[] | null | undefined, + [units?: DurationUnit[], fraction?: boolean] + > + implements PipeTransform +{ + override transform(value: D[] | string[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string { + return super.transform(value, units, fraction); + } + + protected format(value: D[] | string[] | null | undefined, 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..962f400307 100644 --- a/packages/components/core/formatters/date/formatter.spec.ts +++ b/packages/components/core/formatters/date/formatter.spec.ts @@ -1,9 +1,19 @@ -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 { + AbsoluteDateFormatterPipe, + AbsoluteDateShortFormatterPipe, + AbsoluteDateTimeFormatterPipe, + AbsoluteShortDateTimeFormatterPipe, DateAdapter, DateFormatter, + DurationLongFormatterImpurePipe, + DurationLongFormatterPipe, + DurationShortestFormatterImpurePipe, + DurationShortestFormatterPipe, + DurationShortFormatterImpurePipe, + DurationShortFormatterPipe, KBQ_DEFAULT_LOCALE_DATA_FACTORY, KBQ_LOCALE_DATA, KBQ_LOCALE_ID, @@ -12,6 +22,9 @@ import { KbqAbsoluteLongDateTimePipe, KbqAbsoluteShortDatePipe, KbqAbsoluteShortDateTimePipe, + KbqDurationLongPipe, + KbqDurationShortestPipe, + KbqDurationShortPipe, KbqFormattersModule, KbqLocaleService, KbqRangeLongDatePipe, @@ -22,10 +35,39 @@ import { KbqRelativeLongDatePipe, KbqRelativeLongDateTimePipe, KbqRelativeShortDatePipe, - KbqRelativeShortDateTimePipe + KbqRelativeShortDateTimePipe, + RangeDateFormatterPipe, + RangeDateTimeFormatterPipe, + RangeMiddleDateTimeFormatterPipe, + RangeShortDateFormatterPipe, + RangeShortDateTimeFormatterPipe, + RelativeDateFormatterPipe, + RelativeDateTimeFormatterPipe, + RelativeShortDateFormatterPipe, + RelativeShortDateTimeFormatterPipe } from '@koobiq/components/core'; +import { DurationUnit } from '@koobiq/date-adapter'; 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(); +}; + +/** + * 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; let formatter: DateFormatter; @@ -2436,8 +2478,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); }); @@ -2493,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; @@ -2541,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); @@ -2549,24 +2594,609 @@ 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('')); + }); + }); + + 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([]); + } + + // 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([]); + } + + @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)) + ); + }); + + // `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); + + 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', () => { + // 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][] = [ + ['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]], + ['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)); + + 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([]); + } + + 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. 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. + 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] + ]; + + it.each(emptyCases)('renders an empty string for %s', (_, range) => { + const fixture = TestBed.createComponent(RangeBoundsHostComponent); + + fixture.componentInstance.range.set(range); + + 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)); + // `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(''); + expect(read(fixture, 'pureMidTime')).toBe(''); + }); + }); + + 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`. + @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]; + } + } + + // 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, + RebuiltUnitsHostComponent, + 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); + }); + + 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); }); }); }); 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..6143ea8b51 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'; @@ -70,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) @@ -80,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) @@ -90,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) @@ -100,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) @@ -110,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) @@ -120,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) @@ -130,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) @@ -140,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) @@ -186,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; @@ -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[] | null | undefined, 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[] | null | undefined, 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[] | null | undefined, 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[] | null | undefined, 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[] | null | undefined, 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[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration, "durationShort", true>; +} + // @public (undocumented) export const E = 69; @@ -2386,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) @@ -2398,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) @@ -2410,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) @@ -2422,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) @@ -2651,6 +2712,48 @@ 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[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + transform(value: D[] | string[] | null | undefined, 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[] | null | undefined, options?: DateTimeOptions): string; + // (undocumented) + transform(value: D[] | string[] | null | undefined, 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[] | null | undefined, units?: DurationUnit[], fraction?: boolean): string; + // (undocumented) + transform(value: D[] | string[] | null | undefined, 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 +2821,7 @@ export class KbqFormattersModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -3386,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(input: 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) @@ -3398,11 +3501,11 @@ export class KbqRangeLongDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRangeLongDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(input: 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) @@ -3410,11 +3513,11 @@ export class KbqRangeLongDateTimePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRangeMiddleDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(input: 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) @@ -3422,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(input: 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) @@ -3434,11 +3537,11 @@ export class KbqRangeShortDatePipe extends BaseLocaleAwareFormatterPipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { +export class KbqRangeShortDateTimePipe extends BaseLocaleAwareFormatterPipe implements PipeTransform { // (undocumented) - protected format(input: 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) @@ -3483,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) @@ -3495,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) @@ -3507,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) @@ -3519,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) @@ -4379,7 +4482,7 @@ export const R = 82; // @public (undocumented) export class RangeDateFormatterImpurePipe extends RangeDateFormatterPipe { // (undocumented) - transform(input: D[] | string[]): string; + transform(value: D[] | string[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4389,7 +4492,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[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4399,7 +4502,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[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4409,7 +4512,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[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4419,7 +4522,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[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4429,7 +4532,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[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4439,7 +4542,7 @@ export class RangeMiddleDateTimeFormatterPipe extends BaseFormatterPipe im // @public (undocumented) export class RangeShortDateFormatterImpurePipe extends RangeShortDateFormatterPipe { // (undocumented) - transform(input: D[] | string[]): string; + transform(value: D[] | string[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4449,7 +4552,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[] | null | undefined): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4459,7 +4562,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[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4469,7 +4572,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[] | null | undefined, options?: DateTimeOptions): string; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented) @@ -4479,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) @@ -4489,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) @@ -4499,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) @@ -4509,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) @@ -4519,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) @@ -4529,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) @@ -4539,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) @@ -4549,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)