From 1979f3b4573315ff4a2b289cc2e645718f33a29c Mon Sep 17 00:00:00 2001 From: Derek Burgman Date: Wed, 7 Dec 2022 16:05:32 -0600 Subject: [PATCH] feat: added dateScheduleRangeField() (#22) --- .../component/selection.calendar.component.ts | 13 +- .../selection.filter.calendar.component.ts | 29 + .../container/calendar.component.html | 21 +- .../extension/container/calendar.component.ts | 30 +- .../modules/extension/doc.extension.module.ts | 5 +- .../modules/form/container/value.component.ts | 2 +- packages/date/src/lib/date/date.block.spec.ts | 17 + packages/date/src/lib/date/date.block.ts | 51 +- packages/date/src/lib/date/date.format.ts | 8 + packages/date/src/lib/date/date.range.ts | 62 ++- .../date/src/lib/date/date.schedule.spec.ts | 2 + packages/date/src/lib/date/date.schedule.ts | 85 ++- .../calendar/src/lib/calendar.module.ts | 21 +- ...endar.schedule.selection.cell.component.ts | 43 ++ ...calendar.schedule.selection.component.html | 11 +- .../calendar.schedule.selection.component.ts | 63 ++- ...dar.schedule.selection.days.component.html | 1 - ...endar.schedule.selection.days.component.ts | 32 +- ....schedule.selection.days.form.component.ts | 18 + ...edule.selection.dialog.button.component.ts | 20 + ...dar.schedule.selection.dialog.component.ts | 26 + .../lib/calendar.schedule.selection.form.ts | 16 + ...dule.selection.popover.button.component.ts | 17 +- ...ar.schedule.selection.range.component.html | 15 +- ...ndar.schedule.selection.range.component.ts | 84 ++- ...lendar.schedule.selection.store.provide.ts | 39 ++ .../calendar.schedule.selection.store.spec.ts | 133 +++++ .../lib/calendar.schedule.selection.store.ts | 496 ++++++++++++++++-- .../src/lib/calendar.schedule.selection.ts | 58 ++ .../dbx-form/calendar/src/lib/field/index.ts | 2 + .../calendar.schedule.field.component.ts | 104 ++++ .../field/schedule/calendar.schedule.field.ts | 23 + .../schedule/calendar.schedule.module.ts | 28 + .../calendar/src/lib/field/schedule/index.ts | 3 + packages/dbx-form/calendar/src/lib/index.ts | 8 + .../src/lib/extension/calendar/_calendar.scss | 51 ++ .../dbx-form/src/lib/style/_all-theme.scss | 2 + packages/dbx-form/src/lib/style/_config.scss | 12 + .../src/lib/extension/calendar/_calendar.scss | 22 +- .../extension/calendar/style/_variables.scss | 24 + .../style/month/calendar-month-view.scss | 30 ++ .../src/lib/interaction/dialog/_dialog.scss | 18 + .../dialog/dialog.content.close.component.ts | 26 + .../dialog/dialog.content.footer.component.ts | 29 + .../lib/interaction/dialog/dialog.module.ts | 10 +- .../src/lib/interaction/dialog/index.ts | 2 + .../dbx-web/src/lib/layout/style/_style.scss | 5 + packages/util/fetch/src/lib/error.spec.ts | 2 + packages/util/fetch/src/lib/url.spec.ts | 4 +- .../util/src/lib/array/array.number.spec.ts | 14 + packages/util/src/lib/array/array.number.ts | 10 +- packages/util/src/lib/date/week.spec.ts | 25 + packages/util/src/lib/date/week.ts | 89 +++- packages/util/src/lib/number/dollar.spec.ts | 4 +- packages/util/src/lib/number/number.ts | 7 + packages/util/src/lib/set/set.spec.ts | 16 +- packages/util/src/lib/set/set.ts | 21 + packages/util/src/lib/sort.spec.ts | 21 + packages/util/src/lib/sort.ts | 48 ++ packages/util/src/lib/value/address.spec.ts | 6 +- 60 files changed, 1955 insertions(+), 129 deletions(-) create mode 100644 apps/demo/src/app/modules/doc/modules/extension/component/selection.filter.calendar.component.ts create mode 100644 packages/dbx-form/calendar/src/lib/calendar.schedule.selection.cell.component.ts delete mode 100644 packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.component.html create mode 100644 packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.form.component.ts create mode 100644 packages/dbx-form/calendar/src/lib/calendar.schedule.selection.dialog.button.component.ts create mode 100644 packages/dbx-form/calendar/src/lib/calendar.schedule.selection.dialog.component.ts create mode 100644 packages/dbx-form/calendar/src/lib/calendar.schedule.selection.form.ts create mode 100644 packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.provide.ts create mode 100644 packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.spec.ts create mode 100644 packages/dbx-form/calendar/src/lib/calendar.schedule.selection.ts create mode 100644 packages/dbx-form/calendar/src/lib/field/index.ts create mode 100644 packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.field.component.ts create mode 100644 packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.field.ts create mode 100644 packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.module.ts create mode 100644 packages/dbx-form/calendar/src/lib/field/schedule/index.ts create mode 100644 packages/dbx-web/src/lib/interaction/dialog/dialog.content.close.component.ts create mode 100644 packages/dbx-web/src/lib/interaction/dialog/dialog.content.footer.component.ts create mode 100644 packages/util/src/lib/array/array.number.spec.ts create mode 100644 packages/util/src/lib/sort.spec.ts diff --git a/apps/demo/src/app/modules/doc/modules/extension/component/selection.calendar.component.ts b/apps/demo/src/app/modules/doc/modules/extension/component/selection.calendar.component.ts index 438283f9b..3323a094d 100644 --- a/apps/demo/src/app/modules/doc/modules/extension/component/selection.calendar.component.ts +++ b/apps/demo/src/app/modules/doc/modules/extension/component/selection.calendar.component.ts @@ -5,9 +5,14 @@ import { DbxCalendarScheduleSelectionStore } from '@dereekb/dbx-form/calendar'; selector: 'doc-extension-calendar-schedule-example', template: ` + +

Selection: {{ calendarSelectionValue$ | async | json }}

+
- + + + @@ -16,4 +21,8 @@ import { DbxCalendarScheduleSelectionStore } from '@dereekb/dbx-form/calendar'; `, providers: [DbxCalendarScheduleSelectionStore] }) -export class DocExtensionCalendarScheduleSelectionComponent {} +export class DocExtensionCalendarScheduleSelectionComponent { + readonly calendarSelectionValue$ = this.dbxCalendarScheduleSelectionStore.currentSelectionValue$; + + constructor(readonly dbxCalendarScheduleSelectionStore: DbxCalendarScheduleSelectionStore) {} +} diff --git a/apps/demo/src/app/modules/doc/modules/extension/component/selection.filter.calendar.component.ts b/apps/demo/src/app/modules/doc/modules/extension/component/selection.filter.calendar.component.ts new file mode 100644 index 000000000..24b81f942 --- /dev/null +++ b/apps/demo/src/app/modules/doc/modules/extension/component/selection.filter.calendar.component.ts @@ -0,0 +1,29 @@ +import { addDays, startOfDay } from 'date-fns'; +import { Component } from '@angular/core'; +import { DbxCalendarScheduleSelectionStore } from '@dereekb/dbx-form/calendar'; +import { DateScheduleDateFilterConfig } from '@dereekb/date'; + +export const DOC_EXTENSION_CALENDAR_SCHEDULE_TEST_FILTER: DateScheduleDateFilterConfig = { + start: startOfDay(new Date()), + end: addDays(new Date(), 14), // two weeks + w: '345', // Tues/Weds/Thurs + ex: [] +}; + +@Component({ + selector: 'doc-extension-calendar-schedule-with-filter-example', + template: ` + + +

Selection: {{ calendarSelectionValue$ | async | json }}

+
+ `, + providers: [DbxCalendarScheduleSelectionStore] +}) +export class DocExtensionCalendarScheduleSelectionWithFilterComponent { + readonly calendarSelectionValue$ = this.dbxCalendarScheduleSelectionStore.currentSelectionValue$; + + constructor(readonly dbxCalendarScheduleSelectionStore: DbxCalendarScheduleSelectionStore) { + dbxCalendarScheduleSelectionStore.setFilter(DOC_EXTENSION_CALENDAR_SCHEDULE_TEST_FILTER); + } +} diff --git a/apps/demo/src/app/modules/doc/modules/extension/container/calendar.component.html b/apps/demo/src/app/modules/doc/modules/extension/container/calendar.component.html index 2cbd88db9..62b888b65 100644 --- a/apps/demo/src/app/modules/doc/modules/extension/container/calendar.component.html +++ b/apps/demo/src/app/modules/doc/modules/extension/container/calendar.component.html @@ -2,15 +2,11 @@

Styles are not required, as the dbx-web library already builds custom/modified styling for use.

+

All style colors are used as css variables, allowing for easy overwriting of values.

- - - - - -

Picked Date: {{ date$ | async | date }}

-

Clicked Event: {{ event | json }}

+ + @@ -25,5 +21,16 @@ + + + + + + + + +

Picked Date: {{ date$ | async | date }}

+

Clicked Event: {{ event | json }}

+
diff --git a/apps/demo/src/app/modules/doc/modules/extension/container/calendar.component.ts b/apps/demo/src/app/modules/doc/modules/extension/container/calendar.component.ts index 1b6dc36fe..9e7331bac 100644 --- a/apps/demo/src/app/modules/doc/modules/extension/container/calendar.component.ts +++ b/apps/demo/src/app/modules/doc/modules/extension/container/calendar.component.ts @@ -1,9 +1,13 @@ import { OnInit, Component } from '@angular/core'; import { DbxCalendarEvent, DbxCalendarStore } from '@dereekb/dbx-web/calendar'; -import { DateBlock, DateBlockCollection, dateBlockTiming, durationSpanToDateRange, expandDateBlockCollection } from '@dereekb/date'; +import { DateBlock, DateBlockCollection, dateBlockTiming, DateScheduleRange, durationSpanToDateRange, expandDateBlockCollection } from '@dereekb/date'; import { addMonths, setHours } from 'date-fns/esm'; import { Maybe, range } from '@dereekb/util'; import { CalendarEvent } from 'angular-calendar'; +import { dateScheduleRangeField } from '@dereekb/dbx-form/calendar'; +import { startOfDay, addDays } from 'date-fns'; +import { Observable, of } from 'rxjs'; +import { DOC_EXTENSION_CALENDAR_SCHEDULE_TEST_FILTER } from '../component/selection.filter.calendar.component'; export interface TestCalendarEventData extends DateBlock { value: string; @@ -18,6 +22,30 @@ export class DocExtensionCalendarComponent implements OnInit { event: Maybe>; + readonly defaultDateScheduleRangeFieldValue$ = of({ + dateSchedule: { + start: startOfDay(new Date()), + end: addDays(startOfDay(new Date()), 14), + w: '8', + ex: [0, 3, 4, 5] + } + }); + + readonly dateScheduleRangeFields = [ + dateScheduleRangeField({ + key: 'dateSchedule', + required: true, + label: 'Custom Label', + description: 'Input field used for picking a DateScheduleRange value.' + }), + dateScheduleRangeField({ + key: 'dateScheduleWithFilter', + required: true, + description: 'Date schedule with a filter applied to it', + filter: DOC_EXTENSION_CALENDAR_SCHEDULE_TEST_FILTER + }) + ]; + readonly date$ = this.calendarStore.date$; constructor(readonly calendarStore: DbxCalendarStore) {} diff --git a/apps/demo/src/app/modules/doc/modules/extension/doc.extension.module.ts b/apps/demo/src/app/modules/doc/modules/extension/doc.extension.module.ts index 9879cb452..be279ac9f 100644 --- a/apps/demo/src/app/modules/doc/modules/extension/doc.extension.module.ts +++ b/apps/demo/src/app/modules/doc/modules/extension/doc.extension.module.ts @@ -18,7 +18,8 @@ import { DocExtensionMapboxContentExampleComponent } from './component/mapbox.co import { DocExtensionMapboxMarkersExampleComponent } from './component/mapbox.markers.example.component'; import { DbxCalendarRootModule } from '@dereekb/dbx-web/calendar'; import { DocExtensionCalendarScheduleSelectionComponent } from './component/selection.calendar.component'; -import { DbxFormCalendarModule } from '@dereekb/dbx-form/calendar'; +import { DbxFormCalendarModule, DbxFormDateScheduleRangeFieldModule } from '@dereekb/dbx-form/calendar'; +import { DocExtensionCalendarScheduleSelectionWithFilterComponent } from './component/selection.filter.calendar.component'; @NgModule({ imports: [ @@ -26,6 +27,7 @@ import { DbxFormCalendarModule } from '@dereekb/dbx-form/calendar'; DbxCalendarRootModule, DbxFormCalendarModule, DocFormComponentsModule, + DbxFormDateScheduleRangeFieldModule, DbxWidgetModule, DbxMapboxModule, NgxMapboxGLModule, @@ -41,6 +43,7 @@ import { DbxFormCalendarModule } from '@dereekb/dbx-form/calendar'; DocExtensionMapboxContentExampleComponent, DocExtensionMapboxMarkersExampleComponent, DocExtensionCalendarScheduleSelectionComponent, + DocExtensionCalendarScheduleSelectionWithFilterComponent, // container DocExtensionLayoutComponent, DocExtensionHomeComponent, diff --git a/apps/demo/src/app/modules/doc/modules/form/container/value.component.ts b/apps/demo/src/app/modules/doc/modules/form/container/value.component.ts index baa557d1d..92351977d 100644 --- a/apps/demo/src/app/modules/doc/modules/form/container/value.component.ts +++ b/apps/demo/src/app/modules/doc/modules/form/container/value.component.ts @@ -1,10 +1,10 @@ -import { DateScheduleDayCode } from './../../../../../../../../../packages/date/src/lib/date/date.schedule'; import { FormlyFieldConfig } from '@ngx-formly/core'; import { Component } from '@angular/core'; import { addressField, addressListField, cityField, countryField, emailField, phoneField, nameField, phoneAndLabelSectionField, wrappedPhoneAndLabelField, repeatArrayField, stateField, textAreaField, textField, zipCodeField, phoneListField, dateTimeField, DbxDateTimeFieldTimeMode, toggleField, checkboxField, numberField, latLngTextField, DbxDateTimeValueMode, dateRangeField, dollarAmountField, DateTimePickerConfiguration } from '@dereekb/dbx-form'; import { addDays, startOfDay } from 'date-fns'; import { addSuffixFunction, randomBoolean } from '@dereekb/util'; import { of } from 'rxjs'; +import { DateScheduleDayCode } from '@dereekb/date'; @Component({ templateUrl: './value.component.html' diff --git a/packages/date/src/lib/date/date.block.spec.ts b/packages/date/src/lib/date/date.block.spec.ts index e3eb988c1..994068a87 100644 --- a/packages/date/src/lib/date/date.block.spec.ts +++ b/packages/date/src/lib/date/date.block.spec.ts @@ -17,6 +17,7 @@ import { dateBlocksInDateBlockRange, dateBlockTiming, DateBlockTiming, + dateTimingRelativeIndexFactory, expandDateBlockRange, expandUniqueDateBlocksFunction, getCurrentDateBlockTimingOffset, @@ -109,6 +110,22 @@ describe('getCurrentDateBlockTimingStartDate()', () => { }); }); +describe('dateTimingRelativeIndexFactory()', () => { + describe('scenarios', () => { + describe('timezone change', () => { + const start = new Date('2023-03-12T06:00:00.000Z'); + const dstDay = addDays(start, 1); + + it('should handle daylight savings time changes.', () => { + const factory = dateTimingRelativeIndexFactory({ start }); + const result = factory(dstDay); + + expect(result).toBe(1); + }); + }); + }); +}); + describe('getRelativeIndexForDateTiming()', () => { const start = startOfDay(new Date()); const startsAt = addHours(start, 12); // Noon on the day diff --git a/packages/date/src/lib/date/date.block.ts b/packages/date/src/lib/date/date.block.ts index 92e82bfb3..5a7c77145 100644 --- a/packages/date/src/lib/date/date.block.ts +++ b/packages/date/src/lib/date/date.block.ts @@ -1,7 +1,7 @@ import { DayOfWeek, RequiredOnKeys, IndexNumber, IndexRange, indexRangeCheckFunction, IndexRef, MINUTES_IN_DAY, MS_IN_DAY, UniqueModel, lastValue, FactoryWithRequiredInput, FilterFunction, mergeFilterFunctions, range, Milliseconds, Hours, MapFunction, getNextDay, SortCompareFunction, sortAscendingIndexNumberRefFunction, mergeArrayIntoArray, Configurable, ArrayOrValue, asArray, sumOfIntegersBetween, filterMaybeValues, Maybe } from '@dereekb/util'; import { dateRange, DateRange, DateRangeDayDistanceInput, DateRangeStart, DateRangeType, isDateRange, isDateRangeStart } from './date.range'; import { DateDurationSpan } from './date.duration'; -import { differenceInDays, differenceInMilliseconds, isBefore, addDays, addMinutes, getSeconds, getMilliseconds, getMinutes, addMilliseconds, hoursToMilliseconds, addHours, differenceInHours, isAfter } from 'date-fns'; +import { differenceInDays, differenceInMilliseconds, isBefore, addDays, addMinutes, getSeconds, getMilliseconds, getMinutes, addMilliseconds, hoursToMilliseconds, addHours, differenceInHours, isAfter, millisecondsToHours, minutesToHours } from 'date-fns'; import { isDate, copyHoursAndMinutesFromDate, roundDownToMinute } from './date'; import { Expose, Type } from 'class-transformer'; import { getCurrentSystemOffsetInHours } from './date.timezone'; @@ -142,12 +142,18 @@ export type DateTimingRelativeIndexFactory(timing: T): DateTimingRelativeIndexFactory { const startDate = getCurrentDateBlockTimingStartDate(timing); + const baseOffset = startDate.getTimezoneOffset(); + const factory = ((input: DateOrDateBlockIndex) => { if (typeof input === 'number') { return input; } else { - const diff = differenceInHours(input, startDate); + const inputOffset = input.getTimezoneOffset(); + const offsetDifferenceHours = minutesToHours(baseOffset - inputOffset); // handle timezone offset changes + + const diff = differenceInHours(input, startDate) + offsetDifferenceHours; const daysOffset = Math.floor(diff / 24); + return daysOffset; } }) as Configurable>; @@ -161,10 +167,46 @@ export function dateTimingRelativeIndexFactory = ((input: DateOrDateBlockIndex) => Date) & { + readonly _timing: T; +}; + +/** + * Creates a DateBlockTimingDateFactory. + * + * @param timing + * @returns + */ +export function dateBlockTimingDateFactory(timing: T): DateBlockTimingDateFactory { + const startDate = getCurrentDateBlockTimingStartDate(timing); + const factory = ((input: DateOrDateBlockIndex) => { + if (isDate(input)) { + return input; + } else { + return addDays(startDate, input); // TODO: Is this right to use days, or should it use hours to avoid daylight savings? + } + }) as Configurable>; + factory._timing = timing; + return factory as DateBlockTimingDateFactory; +} + +/** + * Returns the date of the input index. + * + * @param timing + * @param date + */ +export function getRelativeDateForDateBlockTiming(timing: DateBlockTimingStart, input: DateOrDateBlockIndex): Date { + return dateBlockTimingDateFactory(timing)(input); +} + /** * The DateRange input for dateBlockTiming() */ @@ -286,7 +328,8 @@ export type DateBlockDayOfWeekFactory = MapFunction; * @param dayForIndexZero * @returns */ -export function dateBlockDayOfWeekFactory(dayForIndexZero: DayOfWeek): DateBlockDayOfWeekFactory { +export function dateBlockDayOfWeekFactory(inputDayForIndexZero: DayOfWeek | Date): DateBlockDayOfWeekFactory { + const dayForIndexZero = typeof inputDayForIndexZero === 'number' ? inputDayForIndexZero : (inputDayForIndexZero.getUTCDay() as DayOfWeek); return (index: DateBlockIndex) => getNextDay(dayForIndexZero, index); } diff --git a/packages/date/src/lib/date/date.format.ts b/packages/date/src/lib/date/date.format.ts index 4c0bd4e4e..fbcec4e92 100644 --- a/packages/date/src/lib/date/date.format.ts +++ b/packages/date/src/lib/date/date.format.ts @@ -17,6 +17,14 @@ export function formatToISO8601DayString(date: Date = new Date()): ISO8601DayStr return format(date, 'yyyy-MM-dd'); } +export function formatToShortDateString(date: Date = new Date()): ISO8601DayString { + return format(date, 'MM/dd/yyyy'); +} + +export function formatToMonthDayString(date: Date = new Date()): ISO8601DayString { + return format(date, 'MM/dd'); +} + export function formatToDateString(date: Date): string { return format(date, 'EEE, MMM do'); } diff --git a/packages/date/src/lib/date/date.range.ts b/packages/date/src/lib/date/date.range.ts index 9ea27f3d8..241f9c241 100644 --- a/packages/date/src/lib/date/date.range.ts +++ b/packages/date/src/lib/date/date.range.ts @@ -1,6 +1,8 @@ +import { Building, Maybe } from '@dereekb/util'; import { Expose, Type } from 'class-transformer'; import { IsEnum, IsOptional, IsDate, IsNumber } from 'class-validator'; import { addDays, addHours, endOfDay, endOfMonth, endOfWeek, isDate, isPast, startOfDay, startOfMinute, startOfMonth, startOfWeek } from 'date-fns'; +import { isSameDate } from './date'; /** * Represents a start date. @@ -66,6 +68,10 @@ export function isDateRange(input: unknown): input is DateRange { return typeof input === 'object' && isDate((input as DateRange).start) && isDate((input as DateRange).end); } +export function isSameDateRange(a: Maybe, b: Maybe): boolean { + return isSameDate(a?.start, b?.start) && isSameDate(a?.end, b?.end); +} + export enum DateRangeType { /** * Includes only the target day. @@ -279,16 +285,16 @@ export function dateRangeState({ start, end }: DateRange): DateRangeState { } } -export type DateRangeFunctionDateRangeRef = { - _dateRange: T; +export type DateRangeFunctionDateRangeRef = DateRange> = { + readonly _dateRange: T; }; /** - * Returns true if the input date is contained within the configured DateRange. + * Returns true if the input date is contained within the configured DateRange or DateRangeStart. */ -export type IsDateInDateRangeFunction = ((date: Date) => boolean) & DateRangeFunctionDateRangeRef; +export type IsDateInDateRangeFunction = DateRange> = ((date: Date) => boolean) & DateRangeFunctionDateRangeRef; -export function isDateInDateRange(date: Date, dateRange: DateRange): boolean { +export function isDateInDateRange(date: Date, dateRange: Partial): boolean { return isDateInDateRangeFunction(dateRange)(date); } @@ -298,18 +304,38 @@ export function isDateInDateRange(date: Date, dateRange: DateRange): boolean { * @param dateRange * @returns */ -export function isDateInDateRangeFunction(dateRange: T): IsDateInDateRangeFunction { - const startTime = dateRange.start.getTime(); - const endTime = dateRange.end.getTime(); - - const fn = ((input: Date) => { - const time = input.getTime(); - return time >= startTime && time <= endTime; - }) as IsDateInDateRangeFunction; +export function isDateInDateRangeFunction>(dateRange: T): IsDateInDateRangeFunction { + let fn: Building>; + + if (dateRange.start != null && dateRange.end != null) { + // Start And End + const startTime = dateRange.start.getTime(); + const endTime = dateRange.end.getTime(); + fn = ((input: Date) => { + const time = input.getTime(); + return time >= startTime && time <= endTime; + }) as IsDateInDateRangeFunction; + } else if (dateRange.start != null) { + // Start Only + const startTime = dateRange.start.getTime(); + fn = ((input: Date) => { + const time = input.getTime(); + return time >= startTime; + }) as IsDateInDateRangeFunction; + } else if (dateRange.end != null) { + // End Only + const endTime = dateRange.end.getTime(); + fn = ((input: Date) => { + const time = input.getTime(); + return time <= endTime; + }) as IsDateInDateRangeFunction; + } else { + fn = ((input: Date) => false) as IsDateInDateRangeFunction; + } fn._dateRange = dateRange; - return fn; + return fn as IsDateInDateRangeFunction; } /** @@ -333,11 +359,11 @@ export function isDateRangeInDateRangeFunction( const fn = ((input: DateRange) => { return input.start.getTime() >= startTime && input.end.getTime() <= endTime; - }) as IsDateRangeInDateRangeFunction; + }) as Building>; fn._dateRange = dateRange; - return fn; + return fn as IsDateRangeInDateRangeFunction; } /** @@ -361,9 +387,9 @@ export function dateRangeOverlapsDateRangeFunction { return input.start.getTime() <= endTime && input.end.getTime() >= startTime; - }) as DateRangeOverlapsDateRangeFunction; + }) as Building>; fn._dateRange = dateRange; - return fn; + return fn as DateRangeOverlapsDateRangeFunction; } diff --git a/packages/date/src/lib/date/date.schedule.spec.ts b/packages/date/src/lib/date/date.schedule.spec.ts index 999909444..1ca016146 100644 --- a/packages/date/src/lib/date/date.schedule.spec.ts +++ b/packages/date/src/lib/date/date.schedule.spec.ts @@ -120,6 +120,8 @@ describe('dateScheduleDateFilter()', () => { expect(results[3]).toBeSameSecondAs(addDays(start, 9)); }); }); + + // TODO: Test max date range }); }); }); diff --git a/packages/date/src/lib/date/date.schedule.ts b/packages/date/src/lib/date/date.schedule.ts index 18c8dff76..df2ff2564 100644 --- a/packages/date/src/lib/date/date.schedule.ts +++ b/packages/date/src/lib/date/date.schedule.ts @@ -1,10 +1,11 @@ -import { StringOrder, Maybe, mergeArrayIntoArray, firstValueFromIterable, DayOfWeek, addToSet, Day, range, DecisionFunction, FilterFunction, IndexRange, invertFilter, dayOfWeek } from '@dereekb/util'; +import { StringOrder, Maybe, mergeArrayIntoArray, firstValueFromIterable, DayOfWeek, addToSet, Day, range, DecisionFunction, FilterFunction, IndexRange, invertFilter, dayOfWeek, asArray, enabledDaysFromDaysOfWeek, EnabledDays, daysOfWeekFromEnabledDays, setsAreEquivalent, iterablesAreSetEquivalent } from '@dereekb/util'; import { Expose } from 'class-transformer'; import { IsString, Matches, IsOptional, Min, IsArray } from 'class-validator'; import { differenceInDays, getDay } from 'date-fns'; -import { DateBlock, dateBlockDayOfWeekFactory, DateBlockDurationSpan, DateBlockIndex, dateBlockIndexRange, DateBlockRange, DateBlocksExpansionFactory, dateBlocksExpansionFactory, DateBlockTiming, getCurrentDateBlockTimingStartDate } from './date.block'; +import { isSameDate } from './date'; +import { DateBlock, dateBlockDayOfWeekFactory, DateBlockDurationSpan, DateBlockIndex, dateBlockIndexRange, DateBlockRange, DateBlocksExpansionFactory, dateBlocksExpansionFactory, DateBlockTiming, dateTimingRelativeIndexFactory, getCurrentDateBlockTimingStartDate } from './date.block'; import { dateBlockDurationSpanHasNotStartedFilterFunction, dateBlockDurationSpanHasNotEndedFilterFunction } from './date.filter'; -import { DateRangeStart, DateRangeState } from './date.range'; +import { DateRange, DateRangeStart, DateRangeState, isSameDateRange } from './date.range'; import { YearWeekCodeConfig, yearWeekCodeDateTimezoneInstance } from './date.week'; export enum DateScheduleDayCode { @@ -29,6 +30,29 @@ export enum DateScheduleDayCode { WEEKEND = 9 } +/** + * Creates an EnabledDays from the input. + * + * @param input + * @returns + */ +export function enabledDaysFromDateScheduleDayCodes(input: Maybe>): EnabledDays { + const days = expandDateScheduleDayCodesToDayOfWeekSet(Array.from(new Set(input))); + return enabledDaysFromDaysOfWeek(days); +} + +/** + * Creates an array of simplified DateScheduleDayCode[] values from the input. + * + * @param input + * @returns + */ +export function dateScheduleDayCodesFromEnabledDays(input: Maybe): DateScheduleDayCode[] { + const days = daysOfWeekFromEnabledDays(input); + const scheduleDayCodes = days.map((x) => x + 1); + return simplifyDateScheduleDayCodes(scheduleDayCodes); +} + /** * Encoded days of the week that the job block schedule should contain. */ @@ -53,7 +77,20 @@ export function isDateScheduleEncodedWeek(input: string): input is DateScheduleE * * @param codes */ -export function dateScheduleEncodedWeek(codes: DateScheduleDayCode[]): DateScheduleEncodedWeek { +export function dateScheduleEncodedWeek(codes: Iterable): DateScheduleEncodedWeek { + const result = simplifyDateScheduleDayCodes(codes); + return result.join('') as DateScheduleEncodedWeek; +} + +/** + * Reduces/merges any day codes into more simplified day codes. + * + * For instance, if all days of the week are selected, they will be reduced to "8". + * + * @param codes + * @returns + */ +export function simplifyDateScheduleDayCodes(codes: Iterable): DateScheduleDayCode[] { const codesSet = new Set(codes); const result: DateScheduleDayCode[] = []; @@ -97,7 +134,7 @@ export function dateScheduleEncodedWeek(codes: DateScheduleDayCode[]): DateSched } } - return result.join('') as DateScheduleEncodedWeek; + return result; } /** @@ -193,6 +230,14 @@ export interface DateSchedule { ex?: DateBlockIndex[]; } +export function isSameDateSchedule(a: Maybe, b: Maybe): boolean { + if (a && b) { + return a.w === b.w && iterablesAreSetEquivalent(a.ex, b.ex) && iterablesAreSetEquivalent(a.d, b.d); + } else { + return a == b; + } +} + export class DateSchedule implements DateSchedule { @Expose() @IsString() @@ -212,6 +257,26 @@ export class DateSchedule implements DateSchedule { ex?: DateBlockIndex[]; } +/** + * A schedule that occurs during a specific range. + */ +export interface DateScheduleRange extends DateSchedule, DateRange {} + +/** + * Returns true if both inputs have the same schedule and date range. + * + * @param a + * @param b + * @returns + */ +export function isSameDateScheduleRange(a: Maybe, b: Maybe): boolean { + if (a && b) { + return isSameDateRange(a, b) && isSameDateSchedule(a, b); + } else { + return a == b; + } +} + // MARK: DateScheduleDate /** * DateScheduleDateFilter input. @@ -226,7 +291,7 @@ export type DateScheduleDateFilter = DecisionFunction {} +export interface DateScheduleDateFilterConfig extends DateSchedule, Partial {} /** * Creates a DateScheduleDateFilter. @@ -235,11 +300,13 @@ export interface DateScheduleDateFilterConfig extends DateSchedule, Partial = expandDateScheduleDayCodesToDayOfWeekSet(w); const firstDateDay = getDay(firstDate); const dayForIndex = dateBlockDayOfWeekFactory(firstDateDay); + const dateIndexForDate = dateTimingRelativeIndexFactory({ start: firstDate }); + const maxIndex = end != null ? dateIndexForDate(end) : Number.MAX_SAFE_INTEGER; const includedIndexes = new Set(config.d); const excludedIndexes = new Set(config.ex); @@ -251,11 +318,11 @@ export function dateScheduleDateFilter(config: DateScheduleDateFilterConfig): Da i = input; day = dayForIndex(i); } else { - i = differenceInDays(input, firstDate); + i = dateIndexForDate(input); day = dayOfWeek(input); } - return (allowedDays.has(day) && !excludedIndexes.has(i)) || includedIndexes.has(i); + return (i >= 0 && i < maxIndex && allowedDays.has(day) && !excludedIndexes.has(i)) || includedIndexes.has(i); }; } diff --git a/packages/dbx-form/calendar/src/lib/calendar.module.ts b/packages/dbx-form/calendar/src/lib/calendar.module.ts index 1e0acd9e1..2a85410de 100644 --- a/packages/dbx-form/calendar/src/lib/calendar.module.ts +++ b/packages/dbx-form/calendar/src/lib/calendar.module.ts @@ -2,7 +2,7 @@ import { MatDatepickerModule } from '@angular/material/datepicker'; import { NgModule } from '@angular/core'; import { CalendarDayModule, CalendarModule, CalendarWeekModule, DateAdapter } from 'angular-calendar'; import { CommonModule } from '@angular/common'; -import { DbxButtonModule, DbxPopoverInteractionModule } from '@dereekb/dbx-web'; +import { DbxActionModule, DbxButtonModule, DbxDialogInteractionModule, DbxPopoverInteractionModule } from '@dereekb/dbx-web'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { adapterFactory as dateAdapterFactory } from 'angular-calendar/date-adapters/date-fns'; @@ -17,20 +17,34 @@ import { DbxScheduleSelectionCalendarDateRangeComponent } from './calendar.sched import { DbxCalendarModule } from '@dereekb/dbx-web/calendar'; import { MatFormFieldModule } from '@angular/material/form-field'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { DbxScheduleSelectionCalendarDateDaysFormComponent } from './calendar.schedule.selection.days.form.component'; +import { DbxFormlyModule, DbxFormModule } from '@dereekb/dbx-form'; +import { DbxScheduleSelectionCalendarCellComponent } from './calendar.schedule.selection.cell.component'; +import { DbxCalendarScheduleSelectionStoreInjectionBlockDirective } from './calendar.schedule.selection.store.provide'; +import { DbxScheduleSelectionCalendarDateDialogComponent } from './calendar.schedule.selection.dialog.component'; +import { DbxScheduleSelectionCalendarDateDialogButtonComponent } from './calendar.schedule.selection.dialog.button.component'; const declarations = [ // DbxScheduleSelectionCalendarComponent, + DbxScheduleSelectionCalendarDateDaysComponent, + DbxScheduleSelectionCalendarDateDaysFormComponent, + DbxScheduleSelectionCalendarDateRangeComponent, DbxScheduleSelectionCalendarDatePopoverButtonComponent, + DbxScheduleSelectionCalendarCellComponent, DbxScheduleSelectionCalendarDatePopoverComponent, DbxScheduleSelectionCalendarDatePopoverContentComponent, - DbxScheduleSelectionCalendarDateDaysComponent, - DbxScheduleSelectionCalendarDateRangeComponent + DbxCalendarScheduleSelectionStoreInjectionBlockDirective, + DbxScheduleSelectionCalendarDateDialogComponent, + DbxScheduleSelectionCalendarDateDialogButtonComponent ]; @NgModule({ imports: [ // + DbxActionModule, + DbxFormModule, + DbxFormlyModule, DbxCalendarModule, CommonModule, MatIconModule, @@ -41,6 +55,7 @@ const declarations = [ MatButtonToggleModule, DbxButtonModule, MatDatepickerModule, + DbxDialogInteractionModule, DbxPopoverInteractionModule, CalendarModule, CalendarDayModule, diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.cell.component.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.cell.component.ts new file mode 100644 index 000000000..44c1e095f --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.cell.component.ts @@ -0,0 +1,43 @@ +import { tapLog } from '@dereekb/rxjs'; +import { Component, EventEmitter, Output, OnDestroy, Input, ChangeDetectionStrategy } from '@angular/core'; +import { isSameMonth } from 'date-fns'; +import { CalendarEvent, CalendarMonthViewDay } from 'angular-calendar'; +import { map, shareReplay, BehaviorSubject, Subject, first, throttleTime } from 'rxjs'; +import { MatButtonToggleChange } from '@angular/material/button-toggle'; +import { DateOrDateBlockIndex, formatToTimeAndDurationString } from '@dereekb/date'; +import { DbxCalendarEvent, DbxCalendarStore, prepareAndSortCalendarEvents } from '@dereekb/dbx-web/calendar'; +import { DayOfWeek, DecisionFunction } from '@dereekb/util'; +import { DbxCalendarScheduleSelectionStore } from './calendar.schedule.selection.store'; +import { CalendarScheduleSelectionCellContent, CalendarScheduleSelectionDayState, CalendarScheduleSelectionMetadata } from './calendar.schedule.selection'; + +@Component({ + selector: 'dbx-schedule-selection-calendar-cell', + template: ` + {{ icon }} + {{ text }} + `, + host: { + class: 'dbx-schedule-selection-calendar-cell' + }, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DbxScheduleSelectionCalendarCellComponent { + content: CalendarScheduleSelectionCellContent = {}; + + get icon() { + return this.content.icon; + } + + get text() { + return this.content.text; + } + + constructor(readonly dbxCalendarScheduleSelectionStore: DbxCalendarScheduleSelectionStore) {} + + @Input() + set day(day: CalendarMonthViewDay) { + this.dbxCalendarScheduleSelectionStore.cellContentFactory$.pipe(first()).subscribe((fn) => { + this.content = fn(day); + }); + } +} diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.component.html b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.component.html index ed51feba1..ca57b1c4b 100644 --- a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.component.html +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.component.html @@ -3,6 +3,15 @@
- +
+ + + +
+ {{ day.badgeTotal }} + {{ day.date | calendarDate: 'monthViewDayNumber':locale }} +
+ +
diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.component.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.component.ts index aadf6e39e..ed9d8d812 100644 --- a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.component.ts +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.component.ts @@ -1,29 +1,76 @@ -import { Component, EventEmitter, Output } from '@angular/core'; +import { tapLog } from '@dereekb/rxjs'; +import { Component, EventEmitter, Output, OnDestroy } from '@angular/core'; import { isSameMonth } from 'date-fns'; -import { CalendarEvent } from 'angular-calendar'; -import { map, shareReplay, withLatestFrom } from 'rxjs'; +import { CalendarEvent, CalendarMonthViewDay } from 'angular-calendar'; +import { map, shareReplay, BehaviorSubject, Subject, first, throttleTime } from 'rxjs'; import { MatButtonToggleChange } from '@angular/material/button-toggle'; -import { formatToTimeAndDurationString } from '@dereekb/date'; +import { DateOrDateBlockIndex, formatToTimeAndDurationString } from '@dereekb/date'; import { DbxCalendarEvent, DbxCalendarStore, prepareAndSortCalendarEvents } from '@dereekb/dbx-web/calendar'; +import { DayOfWeek, DecisionFunction } from '@dereekb/util'; +import { DbxCalendarScheduleSelectionStore } from './calendar.schedule.selection.store'; +import { CalendarScheduleSelectionDayState, CalendarScheduleSelectionMetadata } from './calendar.schedule.selection'; @Component({ selector: 'dbx-schedule-selection-calendar', templateUrl: './calendar.schedule.selection.component.html', providers: [DbxCalendarStore] }) -export class DbxScheduleSelectionCalendarComponent { +export class DbxScheduleSelectionCalendarComponent implements OnDestroy { @Output() clickEvent = new EventEmitter>(); - readonly events$ = this.calendarStore.visibleEvents$.pipe(map(prepareAndSortCalendarEvents), shareReplay(1)); + // refresh any time the selected day function updates + readonly state$ = this.dbxCalendarScheduleSelectionStore.state$; + readonly refresh$ = this.state$.pipe( + throttleTime(100), + map(() => undefined) + ) as Subject; + readonly events$ = this.calendarStore.visibleEvents$.pipe(map(prepareAndSortCalendarEvents), shareReplay(1)); readonly viewDate$ = this.calendarStore.date$; - constructor(readonly calendarStore: DbxCalendarStore) {} + constructor(readonly calendarStore: DbxCalendarStore, readonly dbxCalendarScheduleSelectionStore: DbxCalendarScheduleSelectionStore) {} - dayClicked({ date }: { date: Date }): void {} + dayClicked({ date }: { date: Date }): void { + this.dbxCalendarScheduleSelectionStore.toggleSelectedDates(date); + } eventClicked(action: string, event: CalendarEvent): void { this.clickEvent.emit({ action, event }); } + + beforeMonthViewRender({ body }: { body: CalendarMonthViewDay[] }): void { + this.state$.pipe(first()).subscribe(({ isEnabledDay, indexFactory, isEnabledFilterDay, allowedDaysOfWeek }) => { + body.forEach((viewDay) => { + const { date } = viewDay; + const i = indexFactory(date); + const day = date.getDay(); + + let state: CalendarScheduleSelectionDayState; + + if (!isEnabledFilterDay(i)) { + viewDay.cssClass = 'cal-day-not-applicable'; + state = CalendarScheduleSelectionDayState.NOT_APPLICABLE; + } else if (!allowedDaysOfWeek.has(day as DayOfWeek)) { + viewDay.cssClass = 'cal-day-disabled'; + state = CalendarScheduleSelectionDayState.DISABLED; + } else if (isEnabledDay(i)) { + viewDay.cssClass = 'cal-day-selected'; + state = CalendarScheduleSelectionDayState.SELECTED; + } else { + viewDay.cssClass = 'cal-day-not-selected'; + state = CalendarScheduleSelectionDayState.NOT_SELECTED; + } + + viewDay.meta = { + state, + i + }; + }); + }); + } + + ngOnDestroy(): void { + this.clickEvent.complete(); + } } diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.component.html b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.component.html deleted file mode 100644 index 7c89b545c..000000000 --- a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.component.html +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.component.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.component.ts index 208553b63..398dbebae 100644 --- a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.component.ts +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.component.ts @@ -1,9 +1,33 @@ -import { Component, EventEmitter, Output, OnDestroy } from '@angular/core'; +import { Component } from '@angular/core'; +import { dateScheduleDayCodesFromEnabledDays, enabledDaysFromDateScheduleDayCodes } from '@dereekb/date'; +import { HandleActionFunction } from '@dereekb/dbx-core'; +import { DbxCalendarStore } from '@dereekb/dbx-web/calendar'; +import { IsModifiedFunction } from '@dereekb/rxjs'; +import { setsAreEquivalent } from '@dereekb/util'; +import { map, shareReplay, Observable, of } from 'rxjs'; +import { DbxScheduleSelectionCalendarDateDaysFormValue } from './calendar.schedule.selection.days.form.component'; +import { DbxCalendarScheduleSelectionStore } from './calendar.schedule.selection.store'; @Component({ selector: 'dbx-schedule-selection-calendar-date-days', - templateUrl: './calendar.schedule.selection.days.component.html' + template: ` +
+ +
+ ` }) -export class DbxScheduleSelectionCalendarDateDaysComponent implements OnDestroy { - ngOnDestroy(): void {} +export class DbxScheduleSelectionCalendarDateDaysComponent { + readonly template$: Observable = this.dbxCalendarScheduleSelectionStore.scheduleDays$.pipe(map(enabledDaysFromDateScheduleDayCodes), shareReplay()); + + readonly isFormModified: IsModifiedFunction = (value: DbxScheduleSelectionCalendarDateDaysFormValue) => { + const newSetValue = new Set(dateScheduleDayCodesFromEnabledDays(value)); + return this.dbxCalendarScheduleSelectionStore.scheduleDays$.pipe(map((currentSet) => !setsAreEquivalent(currentSet, newSetValue))); + }; + + constructor(readonly dbxCalendarStore: DbxCalendarStore, readonly dbxCalendarScheduleSelectionStore: DbxCalendarScheduleSelectionStore) {} + + readonly updateScheduleDays: HandleActionFunction = (value) => { + this.dbxCalendarScheduleSelectionStore.setScheduleDays(new Set(dateScheduleDayCodesFromEnabledDays(value))); + return of(true); + }; } diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.form.component.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.form.component.ts new file mode 100644 index 000000000..cee7054d9 --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.days.form.component.ts @@ -0,0 +1,18 @@ +import { Component, EventEmitter, Output, OnDestroy } from '@angular/core'; +import { AbstractSyncFormlyFormDirective, dateTimeField, nameField, provideFormlyContext, toggleField } from '@dereekb/dbx-form'; +import { EnabledDays } from '@dereekb/util'; +import { FormlyFieldConfig } from '@ngx-formly/core'; +import { dbxScheduleSelectionCalendarDateDaysFormFields } from './calendar.schedule.selection.form'; + +export interface DbxScheduleSelectionCalendarDateDaysFormValue extends EnabledDays {} + +@Component({ + template: ` + + `, + selector: 'dbx-schedule-selection-calendar-date-days-form', + providers: [provideFormlyContext()] +}) +export class DbxScheduleSelectionCalendarDateDaysFormComponent extends AbstractSyncFormlyFormDirective { + readonly fields: FormlyFieldConfig[] = dbxScheduleSelectionCalendarDateDaysFormFields(); +} diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.dialog.button.component.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.dialog.button.component.ts new file mode 100644 index 000000000..ae9bffbce --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.dialog.button.component.ts @@ -0,0 +1,20 @@ +import { Component, ElementRef, Injector, Input } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DbxScheduleSelectionCalendarDateDialogComponent } from './calendar.schedule.selection.dialog.component'; + +@Component({ + selector: 'dbx-schedule-selection-calendar-date-dialog-button', + template: ` + + ` +}) +export class DbxScheduleSelectionCalendarDateDialogButtonComponent { + @Input() + buttonText = 'Customize'; + + constructor(readonly matDialog: MatDialog, readonly injector: Injector) {} + + clickCustomize() { + DbxScheduleSelectionCalendarDateDialogComponent.openDialog(this.matDialog, { injector: this.injector }); + } +} diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.dialog.component.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.dialog.component.ts new file mode 100644 index 000000000..c8c28d8b1 --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.dialog.component.ts @@ -0,0 +1,26 @@ +import { Component, ElementRef, Injector } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { AbstractDialogDirective } from '@dereekb/dbx-web'; + +export interface DbxScheduleSelectionCalendarDatePopupConfig { + injector: Injector; +} + +@Component({ + template: ` + + + + + + ` +}) +export class DbxScheduleSelectionCalendarDateDialogComponent extends AbstractDialogDirective { + static openDialog(matDialog: MatDialog, { injector }: DbxScheduleSelectionCalendarDatePopupConfig) { + return matDialog.open(DbxScheduleSelectionCalendarDateDialogComponent, { + injector, + width: '80vw', + minWidth: 460 + }); + } +} diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.form.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.form.ts new file mode 100644 index 000000000..2d8630bc3 --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.form.ts @@ -0,0 +1,16 @@ +import { flexLayoutWrapper, toggleField } from '@dereekb/dbx-form'; +import { getDaysOfWeekNames } from '@dereekb/util'; + +export function dbxScheduleSelectionCalendarDateDaysFormFields() { + const fields = dbxScheduleSelectionCalendarDateDaysFormDayFields(); + return [flexLayoutWrapper(fields, { relative: true, size: 3 })]; +} + +export function dbxScheduleSelectionCalendarDateDaysFormDayFields() { + return getDaysOfWeekNames(false).map((dayOfWeekName: string) => { + return toggleField({ + key: dayOfWeekName.toLowerCase(), + label: dayOfWeekName + }); + }); +} diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.popover.button.component.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.popover.button.component.ts index 0d8cf542c..0668569d4 100644 --- a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.popover.button.component.ts +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.popover.button.component.ts @@ -2,7 +2,9 @@ import { DbxScheduleSelectionCalendarDatePopoverComponent } from './calendar.sch import { Component, ElementRef, Injector, ViewChild } from '@angular/core'; import { DbxPopoverKey, AbstractPopoverDirective, DbxPopoverService } from '@dereekb/dbx-web'; import { NgPopoverRef } from 'ng-overlay-container'; -import { of } from 'rxjs'; +import { of, map, shareReplay } from 'rxjs'; +import { DbxCalendarScheduleSelectionStore } from './calendar.schedule.selection.store'; +import { formatToDateString, formatToMonthDayString, formatToShortDateString } from '@dereekb/date'; @Component({ selector: 'dbx-schedule-selection-calendar-date-popover-button', @@ -14,9 +16,18 @@ export class DbxScheduleSelectionCalendarDatePopoverButtonComponent { @ViewChild('buttonPopoverOrigin', { read: ElementRef }) buttonPopoverOrigin!: ElementRef; - readonly buttonText$ = of('Pick Date Range'); + readonly buttonText$ = this.dbxCalendarScheduleSelectionStore.currentDateRange$.pipe( + map((x) => { + if (x?.start && x.end) { + return `${formatToMonthDayString(x.start)} - ${formatToMonthDayString(x.end)}`; + } else { + return 'Pick a Date Range'; + } + }), + shareReplay(1) + ); - constructor(readonly popoverService: DbxPopoverService, readonly injector: Injector) {} + constructor(readonly popoverService: DbxPopoverService, readonly dbxCalendarScheduleSelectionStore: DbxCalendarScheduleSelectionStore, readonly injector: Injector) {} openPopover() { DbxScheduleSelectionCalendarDatePopoverComponent.openPopover(this.popoverService, { origin: this.buttonPopoverOrigin, injector: this.injector }); diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.range.component.html b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.range.component.html index 2a00827d5..f15bca953 100644 --- a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.range.component.html +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.range.component.html @@ -1,11 +1,18 @@ - - Enter a date range - + + {{ label }} +
+ Custom +
+ - +
+ + +
+ Invalid start date Invalid end date
diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.range.component.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.range.component.ts index c9a741a77..489d8aaa0 100644 --- a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.range.component.ts +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.range.component.ts @@ -1,18 +1,48 @@ +import { switchMap, throttleTime } from 'rxjs/operators'; import { SubscriptionObject } from '@dereekb/rxjs'; -import { Component, OnDestroy } from '@angular/core'; +import { Component, Injector, Input, OnDestroy } from '@angular/core'; import { DbxCalendarScheduleSelectionStore } from './calendar.schedule.selection.store'; import { DbxCalendarStore } from '@dereekb/dbx-web/calendar'; import { FormGroup, FormControl } from '@angular/forms'; -import { Maybe } from '@dereekb/util'; -import { distinctUntilChanged } from 'rxjs'; -import { isSameDate } from '@dereekb/date'; -import { startOfDay } from 'date-fns'; +import { Maybe, randomNumberFactory } from '@dereekb/util'; +import { distinctUntilChanged, filter, BehaviorSubject, startWith, Observable, of } from 'rxjs'; +import { isSameDateDay } from '@dereekb/date'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; +import { MatDialog } from '@angular/material/dialog'; @Component({ selector: 'dbx-schedule-selection-calendar-date-range', templateUrl: './calendar.schedule.selection.range.component.html' }) export class DbxScheduleSelectionCalendarDateRangeComponent implements OnDestroy { + @Input() + required?: boolean; + + @Input() + appearance: MatFormFieldAppearance = 'fill'; + + @Input() + label?: Maybe = 'Enter a date range'; + + @Input() + hint?: Maybe; + + @Input() + set disabled(disabled: Maybe) { + if (disabled) { + this.range.disable(); + } else { + this.range.enable(); + } + } + + @Input() + showCustomize = false; + + readonly random = randomNumberFactory(10000)(); + + private _pickerOpened = new BehaviorSubject(false); + private _syncSub = new SubscriptionObject(); private _valueSub = new SubscriptionObject(); @@ -21,25 +51,57 @@ export class DbxScheduleSelectionCalendarDateRangeComponent implements OnDestroy end: new FormControl>(null) }); + readonly minDate$ = this.dbxCalendarScheduleSelectionStore.minDate$; + readonly maxDate$ = this.dbxCalendarScheduleSelectionStore.maxDate$; + readonly isCustomized$ = this.dbxCalendarScheduleSelectionStore.isCustomized$; + + readonly pickerOpened$ = this._pickerOpened.asObservable(); + constructor(readonly dbxCalendarStore: DbxCalendarStore, readonly dbxCalendarScheduleSelectionStore: DbxCalendarScheduleSelectionStore) {} ngOnInit(): void { - this._syncSub.subscription = this.dbxCalendarScheduleSelectionStore.inputStartAndEnd$.subscribe((x) => { + this._syncSub.subscription = this.dbxCalendarScheduleSelectionStore.inputRange$.subscribe((x) => { this.range.setValue({ start: x.inputStart ?? null, end: x.inputEnd ?? null }); }); - this._valueSub.subscription = this.range.valueChanges.pipe(distinctUntilChanged((a, b) => isSameDate(a.start, b.start) && isSameDate(a.end, b.end))).subscribe((x) => { - let inputStart = x.start ? startOfDay(x.start) : null; - let inputEnd = x.end ? startOfDay(x.end) : null; - this.dbxCalendarScheduleSelectionStore.setInputRange({ inputStart, inputEnd }); - }); + this._valueSub.subscription = this._pickerOpened + .pipe( + distinctUntilChanged(), + switchMap((opened) => { + let obs: Observable<{ start?: Maybe; end?: Maybe }>; + + if (opened) { + obs = of({}); + } else { + obs = this.range.valueChanges.pipe(startWith(this.range.value)); + } + + return obs; + }), + filter((x) => Boolean(x.start && x.end)), + distinctUntilChanged((a, b) => isSameDateDay(a.start, b.start) && isSameDateDay(a.end, b.end)), + throttleTime(100, undefined, { trailing: true }) + ) + .subscribe((x) => { + if (x.start && x.end) { + this.dbxCalendarScheduleSelectionStore.setInputRange({ inputStart: x.start, inputEnd: x.end }); + } + }); } ngOnDestroy(): void { this._syncSub.destroy(); this._valueSub.destroy(); } + + pickerOpened() { + this._pickerOpened.next(true); + } + + pickerClosed() { + this._pickerOpened.next(false); + } } diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.provide.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.provide.ts new file mode 100644 index 000000000..e778c2d70 --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.provide.ts @@ -0,0 +1,39 @@ +import { Directive, Injectable, Injector, Optional, Provider, SkipSelf } from '@angular/core'; +import { DbxCalendarScheduleSelectionStore } from './calendar.schedule.selection.store'; + +/** + * Token used by provideCalendarScheduleSelectionStoreIfDoesNotExist() to prevent injecting a parent DbxCalendarScheduleSelectionStore into the child view. + */ +@Injectable() +export class DbxCalendarScheduleSelectionStoreProviderBlock { + constructor(@SkipSelf() readonly dbxCalendarScheduleSelectionStore: DbxCalendarScheduleSelectionStore) {} +} + +@Directive({ + selector: '[dbxCalendarScheduleSelectionStoreParentBlocker]', + providers: [DbxCalendarScheduleSelectionStoreProviderBlock] +}) +export class DbxCalendarScheduleSelectionStoreInjectionBlockDirective {} + +/** + * Creates a Provider that initializes a new DbxCalendarScheduleSelectionStore if a parent does not exist. + * + * If a DbxCalendarScheduleSelectionStoreInjectionBlock is available in the context, and references the same dbxCalendarScheduleSelectionStore that is attempting to be injected, a new DbxCalendarScheduleSelectionStore is created. + * + * @returns + */ +export function provideCalendarScheduleSelectionStoreIfParentIsUnavailable(): Provider { + return { + provide: DbxCalendarScheduleSelectionStore, + useFactory: (parentInjector: Injector, dbxCalendarScheduleSelectionStoreInjectionBlock?: DbxCalendarScheduleSelectionStoreProviderBlock, dbxCalendarScheduleSelectionStore?: DbxCalendarScheduleSelectionStore) => { + if (!dbxCalendarScheduleSelectionStore || (dbxCalendarScheduleSelectionStore && dbxCalendarScheduleSelectionStoreInjectionBlock != null && dbxCalendarScheduleSelectionStoreInjectionBlock.dbxCalendarScheduleSelectionStore === dbxCalendarScheduleSelectionStore)) { + // create a new dbxCalendarScheduleSelectionStore to use + const injector = Injector.create({ providers: [{ provide: DbxCalendarScheduleSelectionStore }], parent: parentInjector }); + dbxCalendarScheduleSelectionStore = injector.get(DbxCalendarScheduleSelectionStore); + } + + return dbxCalendarScheduleSelectionStore; + }, + deps: [Injector, [new Optional(), DbxCalendarScheduleSelectionStoreProviderBlock], [new Optional(), new SkipSelf(), DbxCalendarScheduleSelectionStore]] + }; +} diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.spec.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.spec.ts new file mode 100644 index 000000000..3f1dba20a --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.spec.ts @@ -0,0 +1,133 @@ +import { lastValue } from '@dereekb/util'; +import { addDays, addHours } from 'date-fns'; +import { DateScheduleDayCode, systemBaseDateToNormalDate, systemNormalDateToBaseDate } from '@dereekb/date'; +import { computeCalendarScheduleSelectionDateBlockRange, initialCalendarScheduleSelectionState, updateStateWithChangedDates, updateStateWithChangedRange, updateStateWithChangedScheduleDays } from './calendar.schedule.selection.store'; + +describe('computeScheduleSelectionValue()', () => { + const start = systemNormalDateToBaseDate(new Date('2022-01-02T00:00:00Z')); // Sunday + + it('should calculate a 3 day selection.', () => { + let state = initialCalendarScheduleSelectionState(); + }); + + describe('schedule days disabled', () => {}); +}); + +describe('isEnabledDayInCalendarScheduleSelectionState()', () => { + describe('function', () => { + describe('scenarios', () => { + describe('12/5/2021-12/31/2023', () => { + const inputStart = new Date('2021-12-06T06:00:00.000Z'); + const inputEnd = new Date('2023-12-29T06:00:00.000Z'); + + let state = initialCalendarScheduleSelectionState(); + state = updateStateWithChangedRange(state, { inputStart, inputEnd }); + state = updateStateWithChangedScheduleDays(state, [DateScheduleDayCode.WEEKDAY]); + + it('Monday March 13 2023 should be enabled', () => { + const date = addHours(new Date('2023-03-12T06:00:00.000Z'), 24); + expect(state.isEnabledDay(date)).toBe(true); + }); + + describe('Saturday and Sunday disabled', () => { + it('Monday March 13 2023 should be enabled', () => { + const date = new Date('2023-03-13T05:00:00.000Z'); + expect(state.isEnabledDay(date)).toBe(true); + }); + }); + }); + }); + }); +}); + +describe('computeCalendarScheduleSelectionDateBlockRange()', () => { + it('should calculate a 4 day selection from the inputStart and inputEnd.', () => { + let state = initialCalendarScheduleSelectionState(); + + const days = 4; + const inputStart = state.start; + const inputEnd = addDays(inputStart, days - 1); + + state = updateStateWithChangedRange(state, { inputStart, inputEnd }); + + const result = computeCalendarScheduleSelectionDateBlockRange(state); + + expect(result?.i).toBe(0); + expect(result?.to).toBe(days - 1); + }); + + it('should calculate a 4 day selection from selected days.', () => { + let state = initialCalendarScheduleSelectionState(); + + const add = [0, 1, 2, 3]; + state = updateStateWithChangedDates(state, { + add + }); + + const result = computeCalendarScheduleSelectionDateBlockRange(state); + + expect(result?.i).toBe(0); + expect(result?.to).toBe(lastValue(add)); + }); + + describe('with range and excluded days', () => { + it('should calculate the selection if all days are excluded from the range.', () => { + let state = initialCalendarScheduleSelectionState(); + + const days = 4; + const inputStart = state.start; + const inputEnd = addDays(inputStart, days - 1); + + state = updateStateWithChangedRange(state, { inputStart, inputEnd }); + + const add = [0, 1, 2, 3]; // exclude the 3rd and 4th days + state = updateStateWithChangedDates(state, { + add + }); + + const result = computeCalendarScheduleSelectionDateBlockRange(state); + expect(result).toBeUndefined(); + }); + + it('should calculate the selection if all days are excluded from the range but one is outside the selection.', () => { + let state = initialCalendarScheduleSelectionState(); + + const days = 3; + const inputStart = state.start; + const inputEnd = addDays(inputStart, days - 1); + + state = updateStateWithChangedRange(state, { inputStart, inputEnd }); + + const onlyEnabledIndex = 3; + const add = [0, 1, 2, onlyEnabledIndex]; // exclude the 3rd and 4th days + state = updateStateWithChangedDates(state, { + add + }); + + const result = computeCalendarScheduleSelectionDateBlockRange(state); + expect(result).toBeDefined(); + expect(result?.i).toBe(onlyEnabledIndex); + expect(result?.to).toBe(onlyEnabledIndex); + }); + + it('should calculate the selection from the inputStart and inputEnd and excluded days.', () => { + let state = initialCalendarScheduleSelectionState(); + + const days = 4; + const inputStart = state.start; + const inputEnd = addDays(inputStart, days - 1); + + state = updateStateWithChangedRange(state, { inputStart, inputEnd }); + + const add = [2, 3]; // exclude the 3rd and 4th days + state = updateStateWithChangedDates(state, { + add + }); + + const result = computeCalendarScheduleSelectionDateBlockRange(state); + + expect(result?.i).toBe(0); + expect(result?.to).toBe(1); + }); + }); +}); diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.ts index da9f27dbc..5d73c304c 100644 --- a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.ts +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.store.ts @@ -1,42 +1,78 @@ import { Injectable } from '@angular/core'; -import { DateBlockIndex, DateOrDateBlockIndex, DateSchedule, DateScheduleDateFilterConfig, DateScheduleDayCode, isSameDate } from '@dereekb/date'; -import { tapLog } from '@dereekb/rxjs'; -import { addToSetCopy, Maybe, TimezoneString } from '@dereekb/util'; +import { + DateBlockDayOfWeekFactory, + dateBlockDayOfWeekFactory, + DateBlockIndex, + DateBlockRange, + DateBlockRangeWithRange, + dateBlockTimingDateFactory, + DateOrDateBlockIndex, + DateRange, + dateScheduleDateFilter, + DateScheduleDateFilterConfig, + DateScheduleDayCode, + dateScheduleDayCodes, + dateScheduleEncodedWeek, + DateScheduleEncodedWeek, + DateScheduleRange, + DateTimingRelativeIndexFactory, + dateTimingRelativeIndexFactory, + expandDateScheduleDayCodesToDayOfWeekSet, + findMaxDate, + findMinDate, + isDateInDateRangeFunction, + IsDateWithinDateBlockRangeFunction, + isDateWithinDateBlockRangeFunction, + isSameDate, + isSameDateDay, + isSameDateRange, + isSameDateScheduleRange, + systemBaseDateToNormalDate, + systemNormalDateToBaseDate +} from '@dereekb/date'; +import { filterMaybe } from '@dereekb/rxjs'; +import { Maybe, TimezoneString, DecisionFunction, IterableOrValue, iterableToArray, addToSet, toggleInSet, isIndexNumberInIndexRangeFunction, MaybeMap, removeFromSet, excludeValues, minAndMaxNumber, setsAreEquivalent, DayOfWeek, range } from '@dereekb/util'; import { ComponentStore } from '@ngrx/component-store'; -import { CalendarEvent } from 'angular-calendar'; -import { differenceInDays, addDays, endOfDay, endOfMonth, endOfWeek, isSameDay, startOfDay, startOfMonth, startOfWeek, isBefore, isAfter } from 'date-fns'; -import { Observable, distinctUntilChanged, first, map, shareReplay, switchMap, tap } from 'rxjs'; +import { addYears, startOfDay, startOfYear } from 'date-fns'; +import { Observable, distinctUntilChanged, map, shareReplay, filter, share, finalize } from 'rxjs'; +import { CalendarScheduleSelectionCellContentFactory, CalendarScheduleSelectionValue, defaultCalendarScheduleSelectionCellContentFactory } from './calendar.schedule.selection'; export interface CalendarScheduleSelectionInputDateRange { /** * Input Start Date */ - inputStart?: Maybe; + inputStart: Date; /** * Input End Date */ - inputEnd?: Maybe; + inputEnd: Date; } -export interface CalendarScheduleSelectionState extends CalendarScheduleSelectionInputDateRange { +export type PartialCalendarScheduleSelectionInputDateRange = Partial>; + +export interface CalendarScheduleSelectionState extends PartialCalendarScheduleSelectionInputDateRange { /** * Filters the days of the schedule to only allow selecting days in the schedule. */ filter?: Maybe; /** - * Optional timezone string. - * - * If not defined, defaults to the current timezone. - * - * When set will update the start Date. + * Minimum date allowed if no filter is set. If a filter is set, the greater of the two dates is used as the minimum. + */ + minDate?: Maybe; + /** + * Maximum date allowed if no fitler is set. If a filter is set, the lesser of the two dates is used as the maximum. */ - timezone?: Maybe; + maxDate?: Maybe; /** * Start date. Is updated as the inputStart is modified or filter is provided that provides the start date. * * Defaults to today and the current timezone. */ start: Date; + /** + * DateTimingRelativeIndexFactory + */ + indexFactory: DateTimingRelativeIndexFactory; /** * Array of manually selected dates. * @@ -48,21 +84,65 @@ export interface CalendarScheduleSelectionState extends CalendarScheduleSelectio * * These indexes are relative to the start date. */ - selectedDates: Set; + selectedIndexes: Set; /** * Days of the schedule that are allowed to be picked. */ - scheduleDays?: Set; + scheduleDays: Set; + /** + * Set of the days of week that are allowed. + */ + allowedDaysOfWeek: Set; + /** + * + */ + indexDayOfWeek: DateBlockDayOfWeekFactory; + /** + * Decision function that returns true if a value is enabled given the current filter. + */ + isEnabledFilterDay: DecisionFunction; + /** + * Decision function that returns true if a value is enabled. + * + * This function does not take the current filter into account. + */ + isEnabledDay: DecisionFunction; + /** + * CalendarScheduleSelectionCellContentFactory for the view. + */ + cellContentFactory: CalendarScheduleSelectionCellContentFactory; + /** + * Current selection value. + */ + currentSelectionValue?: Maybe; +} + +export function initialCalendarScheduleSelectionState(): CalendarScheduleSelectionState { + const start = startOfDay(new Date()); + const scheduleDays = new Set([DateScheduleDayCode.WEEKDAY, DateScheduleDayCode.WEEKEND]); + const allowedDaysOfWeek = expandDateScheduleDayCodesToDayOfWeekSet(Array.from(scheduleDays)); + const indexFactory = dateTimingRelativeIndexFactory({ start }); + const indexDayOfWeek = dateBlockDayOfWeekFactory(start); + + return { + start, + indexFactory, + selectedIndexes: new Set(), + scheduleDays, + allowedDaysOfWeek, + indexDayOfWeek, + isEnabledFilterDay: () => true, + isEnabledDay: () => false, + minDate: new Date(0), + maxDate: startOfYear(addYears(new Date(), 100)), + cellContentFactory: defaultCalendarScheduleSelectionCellContentFactory + }; } @Injectable() export class DbxCalendarScheduleSelectionStore extends ComponentStore { constructor() { - super({ - start: startOfDay(new Date()), - inputStart: new Date(), - selectedDates: new Set() - }); + super(initialCalendarScheduleSelectionState()); } // MARK: Accessors @@ -77,39 +157,383 @@ export class DbxCalendarScheduleSelectionStore extends ComponentStore x.inputEnd), distinctUntilChanged(isSameDate), shareReplay(1) ); - readonly inputStartAndEnd$: Observable = this.state$.pipe( + readonly currentInputRange$: Observable> = this.state$.pipe( map(({ inputStart, inputEnd }) => ({ inputStart, inputEnd })), distinctUntilChanged((a, b) => isSameDate(a.inputStart, b.inputStart) && isSameDate(a.inputEnd, b.inputEnd)), + map((x) => { + if (Boolean(x.inputStart && x.inputEnd)) { + return x as CalendarScheduleSelectionInputDateRange; + } else { + return undefined; + } + }), shareReplay(1) ); + readonly inputRange$: Observable = this.currentInputRange$.pipe(filterMaybe(), shareReplay(1)); + readonly selectedDates$: Observable> = this.state$.pipe( - map((x) => x.selectedDates), + map((x) => x.selectedIndexes), distinctUntilChanged(), shareReplay(1) ); - // MARK: State Changes - readonly setFilter = this.updater((state, filter: Maybe) => ({ ...state, filter })); + readonly isEnabledFilterDayFunction$: Observable> = this.state$.pipe( + map((x) => x.isEnabledFilterDay), + shareReplay(1) + ); - /** - * Set or clears the DateScheduleDateFilterConfig - */ - readonly clearFilter = this.updater((state) => ({ ...state, filter: undefined })); + readonly isEnabledDayFunction$: Observable> = this.state$.pipe( + map((x) => x.isEnabledDay), + shareReplay(1) + ); + + readonly currentDateRange$: Observable> = this.state$.pipe( + map(computeCalendarScheduleSelectionRange), + distinctUntilChanged((a, b) => isSameDateRange(a, b)), + shareReplay(1) + ); + + readonly dateRange$: Observable = this.currentDateRange$.pipe(filterMaybe(), shareReplay(1)); + + readonly scheduleDays$: Observable> = this.state$.pipe( + map((x) => x.scheduleDays), + distinctUntilChanged(setsAreEquivalent), + shareReplay(1) + ); + + readonly currentSelectionValue$ = this.state$.pipe( + map((x) => x.currentSelectionValue), + shareReplay(1) + ); + readonly selectionValue$ = this.currentSelectionValue$.pipe(filterMaybe(), shareReplay(1)); + + readonly currentDateScheduleRangeValue$ = this.currentSelectionValue$.pipe( + map((x) => x?.dateScheduleRange), + distinctUntilChanged(isSameDateScheduleRange), + shareReplay(1) + ); + readonly dateScheduleRangeValue$ = this.currentDateScheduleRangeValue$.pipe(filterMaybe(), shareReplay(1)); + + readonly minDate$ = this.state$.pipe( + map((x) => findMaxDate([x.filter?.start, x.minDate])), + distinctUntilChanged(isSameDateDay), + shareReplay(1) + ); + + readonly maxDate$ = this.state$.pipe( + map((x) => findMinDate([x.filter?.end, x.maxDate])), + distinctUntilChanged(isSameDateDay), + shareReplay(1) + ); + + readonly cellContentFactory$ = this.state$.pipe( + map((x) => x.cellContentFactory), + distinctUntilChanged(), + shareReplay(1) + ); + + readonly isCustomized$ = this.state$.pipe( + map((x) => x.selectedIndexes.size > 0), + distinctUntilChanged(), + shareReplay(1) + ); + + // MARK: State Changes + readonly setFilter = this.updater((state, filter: Maybe) => updateStateWithFilter(state, filter)); + readonly clearFilter = this.updater((state) => updateStateWithFilter(state, undefined)); readonly setTimezone = this.updater((state, timezone: Maybe) => ({ ...state, timezone })); - readonly setInputStartDate = this.updater((state, inputStart: Maybe) => ({ ...state, inputStart })); // TODO: Filter selectedDates based on the input range. - readonly setInputEndDate = this.updater((state, inputEnd: Maybe) => ({ ...state, inputEnd })); // TODO: Filter selectedDates based on the input range. - readonly setInputRange = this.updater((state, { inputStart, inputEnd }: CalendarScheduleSelectionInputDateRange) => ({ ...state, inputStart, inputEnd })); // TODO: Filter selectedDates based on the input range. + readonly setInputRange = this.updater((state, range: CalendarScheduleSelectionInputDateRange) => updateStateWithChangedRange(state, range)); + + readonly toggleSelectedDates = this.updater((state, toggle: IterableOrValue) => updateStateWithChangedDates(state, { toggle })); + readonly addSelectedDates = this.updater((state, add: IterableOrValue) => updateStateWithChangedDates(state, { add })); + readonly removeSelectedDates = this.updater((state, remove: IterableOrValue) => updateStateWithChangedDates(state, { remove })); + readonly setSelectedDates = this.updater((state, set: IterableOrValue) => updateStateWithChangedDates(state, { set })); + + readonly setScheduleDays = this.updater((state, scheduleDays: Iterable) => updateStateWithChangedScheduleDays(state, scheduleDays)); + readonly setAllowAllScheduleDays = this.updater((state) => updateStateWithChangedScheduleDays(state, null)); + + readonly setDateScheduleRangeValue = this.updater((state, value: Maybe) => updateStateWithDateScheduleRangeValue(state, value)); + readonly setCellContentFactory = this.updater((state, cellContentFactory: CalendarScheduleSelectionCellContentFactory) => ({ ...state, cellContentFactory })); +} + +export function updateStateWithFilter(state: CalendarScheduleSelectionState, filter: Maybe): CalendarScheduleSelectionState { + let isEnabledFilterDay: Maybe> = () => true; + + if (filter) { + isEnabledFilterDay = dateScheduleDateFilter(filter); + } + + return { ...state, filter, isEnabledFilterDay }; +} + +export function updateStateWithDateScheduleRangeValue(state: CalendarScheduleSelectionState, change: Maybe): CalendarScheduleSelectionState { + const isSameValue = isSameDateScheduleRange(state.currentSelectionValue?.dateScheduleRange, change); + + if (isSameValue) { + return state; + } else { + if (change != null) { + const nextState: CalendarScheduleSelectionState = { ...state, inputStart: change.start, inputEnd: change.end, selectedIndexes: new Set(change.ex) }; + return updateStateWithChangedScheduleDays(finalizeNewCalendarScheduleSelectionState(nextState), dateScheduleDayCodes(change.w)); + } else { + return noSelectionCalendarScheduleSelectionState(state); // clear selection, retain disabled days + } + } +} + +export function updateStateWithChangedScheduleDays(state: CalendarScheduleSelectionState, change: Maybe>): CalendarScheduleSelectionState { + const { scheduleDays: currentScheduleDays } = state; + const scheduleDays = new Set(change || [DateScheduleDayCode.WEEKDAY, DateScheduleDayCode.WEEKEND]); + + if (setsAreEquivalent(currentScheduleDays, scheduleDays)) { + return state; // no change + } else { + const allowedDaysOfWeek = expandDateScheduleDayCodesToDayOfWeekSet(Array.from(scheduleDays)); + const nextState = { ...state, scheduleDays, allowedDaysOfWeek }; + return finalizeNewCalendarScheduleSelectionState(nextState); + } +} + +export interface CalendarScheduleSelectionStateDatesChange { + toggle?: IterableOrValue; + add?: IterableOrValue; + remove?: IterableOrValue; + set?: IterableOrValue; +} + +export function updateStateWithChangedDates(state: CalendarScheduleSelectionState, change: CalendarScheduleSelectionStateDatesChange): CalendarScheduleSelectionState { + const { indexFactory, allowedDaysOfWeek, indexDayOfWeek } = state; + let selectedIndexes: Set; + + if (change.set) { + selectedIndexes = new Set(iterableToArray(change.set).map(indexFactory)); + } else { + selectedIndexes = new Set(state.selectedIndexes); + + if (change.toggle) { + const allowedToToggle = iterableToArray(change.toggle) + .map(indexFactory) + .filter((i) => allowedDaysOfWeek.has(indexDayOfWeek(i))); + toggleInSet(selectedIndexes, allowedToToggle); + } + + if (change.add) { + addToSet(selectedIndexes, iterableToArray(change.add).map(indexFactory)); + } + + if (change.remove) { + addToSet(selectedIndexes, iterableToArray(change.remove).map(indexFactory)); + } + } + + const nextState = { ...state, selectedIndexes }; + nextState.isEnabledDay = isEnabledDayInCalendarScheduleSelectionState(nextState); + + // Recalculate the range and simplified to exclusions + const rangeAndExclusion = computeScheduleSelectionRangeAndExclusion(nextState); + + if (rangeAndExclusion) { + return finalizeNewCalendarScheduleSelectionState({ ...nextState, selectedIndexes: new Set(rangeAndExclusion.excluded), inputStart: rangeAndExclusion.start, inputEnd: rangeAndExclusion.end }); + } else { + // no selected days + return noSelectionCalendarScheduleSelectionState(state); + } +} + +export function noSelectionCalendarScheduleSelectionState(state: CalendarScheduleSelectionState): CalendarScheduleSelectionState { + return finalizeNewCalendarScheduleSelectionState({ ...state, selectedIndexes: new Set(), inputStart: null, inputEnd: null }); +} + +export function updateStateWithChangedRange(state: CalendarScheduleSelectionState, change: CalendarScheduleSelectionInputDateRange): CalendarScheduleSelectionState { + const { inputStart: currentInputStart, inputEnd: currentInputEnd, indexFactory, minDate, maxDate } = state; + + const inputStart = startOfDay(change.inputStart); + const inputEnd = startOfDay(change.inputEnd); + + const isValidRange = minDate != null || maxDate != null ? isDateInDateRangeFunction({ start: minDate ?? undefined, end: maxDate ?? undefined }) : () => true; + + if (!isValidRange(inputStart) || !isValidRange(inputEnd) || (isSameDateDay(inputStart, currentInputStart) && isSameDateDay(inputEnd, currentInputEnd))) { + return state; // if no change, return the current state. + } + + // retain all indexes that are within the new range + const minIndex = indexFactory(inputStart); + const maxIndex = indexFactory(inputEnd) + 1; + + const currentIndexes: DateBlockIndex[] = Array.from(state.selectedIndexes); + const isInCurrentRange = isIndexNumberInIndexRangeFunction({ minIndex, maxIndex }); + const excludedIndexesInNewRange = currentIndexes.filter(isInCurrentRange); + + const nextState = { ...state, excludedIndexesInNewRange, inputStart, inputEnd }; + return finalizeNewCalendarScheduleSelectionState(nextState); +} + +export function finalizeNewCalendarScheduleSelectionState(nextState: CalendarScheduleSelectionState): CalendarScheduleSelectionState { + nextState.isEnabledDay = isEnabledDayInCalendarScheduleSelectionState(nextState); + nextState.currentSelectionValue = computeScheduleSelectionValue(nextState); + return nextState; +} + +export function isEnabledDayInCalendarScheduleSelectionState(state: CalendarScheduleSelectionState): DecisionFunction { + const { indexFactory, inputStart, inputEnd, indexDayOfWeek, allowedDaysOfWeek } = state; + + let isInStartAndEndRange: IsDateWithinDateBlockRangeFunction; + + if (inputStart && inputEnd) { + isInStartAndEndRange = isDateWithinDateBlockRangeFunction({ start: state.start, range: { start: inputStart, end: inputEnd } }); + } else { + isInStartAndEndRange = () => false; + } + + return (input: DateOrDateBlockIndex) => { + const index = indexFactory(input); + const dayOfWeek = indexDayOfWeek(index); + + const isInSelectedRange = isInStartAndEndRange(input); + const isSelected = state.selectedIndexes.has(index); + const isAllowedDayOfWeek = allowedDaysOfWeek.has(dayOfWeek); - readonly addSelectedDates = this.updater((state, datesToAdd: DateOrDateBlockIndex) => ({ ...state, selectedDate: addToSetCopy(state.selectedDates, datesToAdd) })); // TODO: Filter selectedDates based on the input range. - readonly setSelectedDates = this.updater((state, selectedDates: Set) => ({ ...state, selectedDates })); // TODO: Filter selectedDates based on the input range. + const result = isAllowedDayOfWeek && ((isInSelectedRange && !isSelected) || (isSelected && !isInSelectedRange)); + return result; + }; +} + +export function computeScheduleSelectionValue(state: CalendarScheduleSelectionState): Maybe { + const { scheduleDays, allowedDaysOfWeek, indexDayOfWeek } = state; + const rangeAndExclusion = computeScheduleSelectionRangeAndExclusion(state); + + if (rangeAndExclusion == null) { + return null; + } + + const { start, end, excluded, dateBlockRange } = rangeAndExclusion; + const indexOffset = dateBlockRange.i; + + const ex: DateBlockIndex[] = excluded + .filter((x) => { + const isExcludedIndex = allowedDaysOfWeek.has(indexDayOfWeek(x)); + return isExcludedIndex; + }) + .map((x) => x - indexOffset); // set to the proper offset + + const w: DateScheduleEncodedWeek = dateScheduleEncodedWeek(scheduleDays); + const d: DateBlockIndex[] = []; // "included" blocks are never used/calculated. + + const dateScheduleRange: DateScheduleRange = { + start, + end, + w, + d, + ex + }; + + return { + dateScheduleRange, + minMaxRange: { start, end } + }; +} - readonly setScheduleDays = this.updater((state, scheduleDays: Set) => ({ ...state, scheduleDays })); +export interface CalendarScheduleSelectionRangeAndExclusion extends DateRange { + dateBlockRange: DateBlockRangeWithRange; + excluded: DateBlockIndex[]; +} + +export function computeScheduleSelectionRangeAndExclusion(state: CalendarScheduleSelectionState): Maybe { + const { isEnabledDay } = state; + const dateFactory = dateBlockTimingDateFactory(state); + const dateBlockRange = computeCalendarScheduleSelectionDateBlockRange(state); + + if (dateBlockRange == null) { + return null; // returns null if no items are selected. + } + + const start = dateFactory(dateBlockRange.i); + const end = dateFactory(dateBlockRange.to); + + const excluded: DateBlockIndex[] = range(dateBlockRange.i, dateBlockRange.to + 1).filter((x) => { + const isExcludedIndex = !isEnabledDay(x); + return isExcludedIndex; + }); + + const result: CalendarScheduleSelectionRangeAndExclusion = { + dateBlockRange, + start, + end, + excluded + }; + + return result; +} + +export function computeCalendarScheduleSelectionRange(state: CalendarScheduleSelectionState): Maybe { + const dateFactory = dateBlockTimingDateFactory(state); + const dateBlockRange = computeCalendarScheduleSelectionDateBlockRange(state); + + if (dateBlockRange != null) { + return { start: dateFactory(dateBlockRange.i), end: dateFactory(dateBlockRange.to as number) }; + } else { + return undefined; + } +} + +export function computeCalendarScheduleSelectionDateBlockRange(state: CalendarScheduleSelectionState): Maybe { + const { indexFactory, inputStart, inputEnd, allowedDaysOfWeek, indexDayOfWeek, isEnabledDay, isEnabledFilterDay } = state; + const enabledSelectedIndexes = Array.from(state.selectedIndexes).filter((i) => allowedDaysOfWeek.has(indexDayOfWeek(i))); + const minAndMaxSelectedValues = minAndMaxNumber(enabledSelectedIndexes); + + let startRange: Maybe; + let endRange: Maybe; + + if (minAndMaxSelectedValues) { + startRange = minAndMaxSelectedValues.min; + endRange = minAndMaxSelectedValues.max; + } + + if (inputStart != null && inputEnd != null) { + const inputStartIndex = indexFactory(inputStart); + const inputEndIndex = indexFactory(inputEnd); + + startRange = startRange != null ? Math.min(inputStartIndex, startRange) : inputStartIndex; + endRange = endRange != null ? Math.max(inputEndIndex, endRange) : inputEndIndex; + } + + if (startRange != null && endRange != null) { + const scanStartIndex = startRange; + const scanEndIndex = endRange; + + // clear start and end + startRange = undefined; + endRange = undefined; + + // if the min is equal to the start index, then we are in the range and need to iterate dates until we find one that is not selected/excluded. + for (let i = scanStartIndex; i <= scanEndIndex; i += 1) { + if (isEnabledFilterDay(i) && isEnabledDay(i)) { + startRange = i; + break; + } + } + + // same with the max + for (let i = scanEndIndex; i >= scanStartIndex; i -= 1) { + if (isEnabledFilterDay(i) && isEnabledDay(i)) { + endRange = i; + break; + } + } + } + + if (startRange != null && endRange != null) { + return { i: startRange, to: endRange }; + } else { + return undefined; + } } diff --git a/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.ts b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.ts new file mode 100644 index 000000000..d8dab323c --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/calendar.schedule.selection.ts @@ -0,0 +1,58 @@ +import { DateBlockIndex, DateBlockRangeWithRange, DateRange, DateScheduleRange } from '@dereekb/date'; +import { FactoryWithRequiredInput } from '@dereekb/util'; +import { CalendarMonthViewDay } from 'angular-calendar'; + +export interface CalendarScheduleSelectionValue { + /** + * Schedule range. + */ + dateScheduleRange: DateScheduleRange; + /** + * Min and max dates in the selection. + */ + minMaxRange: DateRange; +} + +export enum CalendarScheduleSelectionDayState { + NOT_APPLICABLE = 0, + DISABLED = 1, + NOT_SELECTED = 2, + SELECTED = 3 +} + +export interface CalendarScheduleSelectionMetadata { + state: CalendarScheduleSelectionDayState; + i: DateBlockIndex; +} + +export interface CalendarScheduleSelectionCellContent { + icon?: string; + text?: string; +} + +export type CalendarScheduleSelectionCellContentFactory = FactoryWithRequiredInput>; + +export const defaultCalendarScheduleSelectionCellContentFactory: CalendarScheduleSelectionCellContentFactory = (day: CalendarMonthViewDay) => { + let icon; + let text; + + switch (day.meta?.state) { + case CalendarScheduleSelectionDayState.SELECTED: + icon = 'check_box'; + break; + case CalendarScheduleSelectionDayState.DISABLED: + icon = 'block'; + break; + case CalendarScheduleSelectionDayState.NOT_APPLICABLE: + break; + case CalendarScheduleSelectionDayState.NOT_SELECTED: + icon = 'check_box_outline_blank'; + text = 'Add'; + break; + } + + return { + icon, + text + }; +}; diff --git a/packages/dbx-form/calendar/src/lib/field/index.ts b/packages/dbx-form/calendar/src/lib/field/index.ts new file mode 100644 index 000000000..2ebe9ff3f --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/field/index.ts @@ -0,0 +1,2 @@ +export * from './schedule'; +// export * from './selection'; diff --git a/packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.field.component.ts b/packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.field.component.ts new file mode 100644 index 000000000..07b96d3c8 --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.field.component.ts @@ -0,0 +1,104 @@ +import { AbstractControl, FormGroup } from '@angular/forms'; +import { CompactContextStore } from '@dereekb/dbx-web'; +import { Component, NgZone, OnDestroy, OnInit, Optional } from '@angular/core'; +import { FieldTypeConfig, FormlyFieldProps } from '@ngx-formly/core'; +import { Maybe } from '@dereekb/util'; +import { FieldType } from '@ngx-formly/material'; +import { BehaviorSubject, distinctUntilChanged, map, shareReplay, startWith, Subscription, switchMap } from 'rxjs'; +import { filterMaybe, ObservableOrValue, SubscriptionObject, asObservable } from '@dereekb/rxjs'; +import { DateScheduleDateFilterConfig, isSameDateScheduleRange } from '@dereekb/date'; +import { DbxCalendarScheduleSelectionStore } from '../../calendar.schedule.selection.store'; +import { provideCalendarScheduleSelectionStoreIfParentIsUnavailable } from '../../calendar.schedule.selection.store.provide'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; + +export interface DbxFormCalendarDateScheduleRangeFieldProps extends Pick { + appearance?: MatFormFieldAppearance; + hideCustomize?: boolean; + filter?: ObservableOrValue>; +} + +@Component({ + template: ` +
+ + + +
+ `, + providers: [provideCalendarScheduleSelectionStoreIfParentIsUnavailable()] +}) +export class DbxFormCalendarDateScheduleRangeFieldComponent extends FieldType> implements OnInit, OnDestroy { + private _syncSub = new SubscriptionObject(); + private _valueSub = new SubscriptionObject(); + private _filterSub = new SubscriptionObject(); + + private _formControlObs = new BehaviorSubject>(undefined); + readonly formControl$ = this._formControlObs.pipe(filterMaybe()); + + readonly value$ = this.formControl$.pipe( + switchMap((control) => control.valueChanges.pipe(startWith(control.value))), + shareReplay(1) + ); + + constructor(@Optional() readonly compact: CompactContextStore, readonly dbxCalendarScheduleSelectionStore: DbxCalendarScheduleSelectionStore, readonly ngZone: NgZone) { + super(); + } + + get formGroupName(): string { + return this.field.key as string; + } + + get formGroup(): FormGroup { + return this.form as FormGroup; + } + + get appearance(): MatFormFieldAppearance { + return this.props.appearance ?? 'standard'; + } + + get label(): Maybe { + return this.field.props?.label; + } + + get description(): Maybe { + return this.props.description; + } + + get isReadonlyOrDisabled() { + return this.props.readonly || this.disabled; + } + + get showCustomize() { + return !this.props.hideCustomize; + } + + get filter() { + return this.props.filter; + } + + ngOnInit(): void { + this._formControlObs.next(this.formControl); + + this._syncSub.subscription = this.value$.pipe(distinctUntilChanged(isSameDateScheduleRange)).subscribe((x) => { + this.dbxCalendarScheduleSelectionStore.setDateScheduleRangeValue(x); + }); + + this._valueSub.subscription = this.dbxCalendarScheduleSelectionStore.currentDateScheduleRangeValue$.subscribe((x) => { + this.formControl.setValue(x); + }); + + const filter = this.filter; + + if (filter != null) { + this._filterSub.subscription = this.dbxCalendarScheduleSelectionStore.setFilter(asObservable(filter)) as Subscription; + } + } + + override ngOnDestroy(): void { + super.ngOnDestroy(); + this._syncSub.destroy(); + this._valueSub.destroy(); + this._formControlObs.complete(); + this._filterSub.destroy(); + } +} diff --git a/packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.field.ts b/packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.field.ts new file mode 100644 index 000000000..8d7a56d69 --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.field.ts @@ -0,0 +1,23 @@ +import { filter } from 'rxjs'; +import { DEFAULT_LAT_LNG_TEXT_FIELD_PATTERN_MESSAGE, DEFAULT_LAT_LNG_TEXT_FIELD_PLACEHOLDER, DescriptionFieldConfig, FieldConfig, formlyField, LabeledFieldConfig, propsAndConfigForFieldConfig, styleWrapper, validatorsForFieldConfig } from '@dereekb/dbx-form'; +import { LAT_LNG_PATTERN } from '@dereekb/util'; +import { FormlyFieldConfig } from '@ngx-formly/core'; +import { DbxFormCalendarDateScheduleRangeFieldProps } from './calendar.schedule.field.component'; + +export interface DateScheduleRangeFieldConfig extends Omit, DescriptionFieldConfig, Partial, DbxFormCalendarDateScheduleRangeFieldProps {} + +export function dateScheduleRangeField(config: DateScheduleRangeFieldConfig = {}): FormlyFieldConfig { + const { key = 'schedule', filter } = config; + const fieldConfig: FormlyFieldConfig = { + ...formlyField({ + key, + type: 'date-schedule-range', + ...propsAndConfigForFieldConfig(config, { + label: config.label ?? 'Schedule', + filter + }) + }) + }; + + return fieldConfig; +} diff --git a/packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.module.ts b/packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.module.ts new file mode 100644 index 000000000..6579168d3 --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/field/schedule/calendar.schedule.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DbxFormCalendarDateScheduleRangeFieldComponent } from './calendar.schedule.field.component'; +import { FormlyModule } from '@ngx-formly/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { DbxTextModule } from '@dereekb/dbx-web'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { DbxFormCalendarModule } from '../../calendar.module'; + +@NgModule({ + imports: [ + CommonModule, + MatIconModule, + DbxFormCalendarModule, + MatButtonModule, + DbxTextModule, + FormsModule, + ReactiveFormsModule, + MatInputModule, + FormlyModule.forChild({ + types: [{ name: 'date-schedule-range', component: DbxFormCalendarDateScheduleRangeFieldComponent }] + }) + ], + declarations: [DbxFormCalendarDateScheduleRangeFieldComponent] +}) +export class DbxFormDateScheduleRangeFieldModule {} diff --git a/packages/dbx-form/calendar/src/lib/field/schedule/index.ts b/packages/dbx-form/calendar/src/lib/field/schedule/index.ts new file mode 100644 index 000000000..19233ed08 --- /dev/null +++ b/packages/dbx-form/calendar/src/lib/field/schedule/index.ts @@ -0,0 +1,3 @@ +export * from './calendar.schedule.field'; +export * from './calendar.schedule.field.component'; +export * from './calendar.schedule.module'; diff --git a/packages/dbx-form/calendar/src/lib/index.ts b/packages/dbx-form/calendar/src/lib/index.ts index a35cdd92e..343872192 100644 --- a/packages/dbx-form/calendar/src/lib/index.ts +++ b/packages/dbx-form/calendar/src/lib/index.ts @@ -1,8 +1,16 @@ +export * from './field'; export * from './calendar.module'; +export * from './calendar.schedule.selection.cell.component'; export * from './calendar.schedule.selection.component'; export * from './calendar.schedule.selection.days.component'; +export * from './calendar.schedule.selection.days.form.component'; +export * from './calendar.schedule.selection.dialog.component'; +export * from './calendar.schedule.selection.dialog.button.component'; +export * from './calendar.schedule.selection.form'; export * from './calendar.schedule.selection.popover.button.component'; export * from './calendar.schedule.selection.popover.component'; export * from './calendar.schedule.selection.popover.content.component'; export * from './calendar.schedule.selection.range.component'; +export * from './calendar.schedule.selection.store.provide'; export * from './calendar.schedule.selection.store'; +export * from './calendar.schedule.selection'; diff --git a/packages/dbx-form/src/lib/extension/calendar/_calendar.scss b/packages/dbx-form/src/lib/extension/calendar/_calendar.scss index 4c6c38d6e..2fbbbc4a3 100644 --- a/packages/dbx-form/src/lib/extension/calendar/_calendar.scss +++ b/packages/dbx-form/src/lib/extension/calendar/_calendar.scss @@ -5,6 +5,28 @@ // MARK: Mixin @mixin core() { + .dbx-schedule-selection-calendar { + .dbx-calendar { + .cal-month-view .cal-cell-top { + min-height: 50px; + } + } + } + + .dbx-schedule-selection-calendar-date-days { + padding: 4px; + + // Remove padding from form + .mat-form-field-wrapper { + padding-bottom: 6px; + } + + .mat-form-field-infix { + padding: 0 !important; + border: none !important; + } + } + .dbx-schedule-selection-calendar-date-range-field { &.mat-form-field-type-mat-date-range-input .mat-form-field-infix { display: flex; @@ -13,6 +35,35 @@ display: flex; } } + + .date-range-field-customized { + display: flex; + align-items: center; + + .date-range-field-customized-text { + padding: 4px 6px; + margin-right: 3px; + font-size: 0.8em; + font-weight: bold; + border-radius: 25px; + } + } + } + + .dbx-schedule-selection-calendar-cell { + display: flex; + min-height: 56px; + align-items: center; + justify-content: center; + flex-direction: column; + user-select: none; + padding-bottom: 4px; + + > span { + height: 32px; + padding: 4px; + text-align: center; + } } } diff --git a/packages/dbx-form/src/lib/style/_all-theme.scss b/packages/dbx-form/src/lib/style/_all-theme.scss index c97897f89..b5f2f23b3 100644 --- a/packages/dbx-form/src/lib/style/_all-theme.scss +++ b/packages/dbx-form/src/lib/style/_all-theme.scss @@ -2,6 +2,7 @@ @use '../formly/formly'; @use '../layout/layout'; @use '../formly/field/field'; +@use '../extension/extension'; // Includes all theming config @mixin all-component-themes($theme-config) { @@ -9,4 +10,5 @@ @include formly.theme($theme-config); @include field.all-field-theme($theme-config); @include layout.theme($theme-config); + @include extension.all-extension-theme($theme-config); } diff --git a/packages/dbx-form/src/lib/style/_config.scss b/packages/dbx-form/src/lib/style/_config.scss index ce1a97f68..38bedfaf9 100644 --- a/packages/dbx-form/src/lib/style/_config.scss +++ b/packages/dbx-form/src/lib/style/_config.scss @@ -17,10 +17,22 @@ ); } +@function get-dbx-theme-config($theme-config) { + @return map.get($theme-config, 'dbx'); +} + @function get-dbx-form-theme-config($theme-config) { @return map.get($theme-config, 'dbx-form'); } +@function get-dbx-extension-config($theme-config) { + @return map.get(get-dbx-theme-config($theme-config), 'extension'); +} + +@function get-dbx-extension-config-var($theme-config, $item) { + @return map.get(get-dbx-extension-config($theme-config), $item); +} + @function get-dbx-extension-calendar-enabled($theme-config) { @return get-dbx-extension-config-var($theme-config, 'calendar'); } diff --git a/packages/dbx-web/src/lib/extension/calendar/_calendar.scss b/packages/dbx-web/src/lib/extension/calendar/_calendar.scss index 7bb8a3094..deeb7739f 100644 --- a/packages/dbx-web/src/lib/extension/calendar/_calendar.scss +++ b/packages/dbx-web/src/lib/extension/calendar/_calendar.scss @@ -127,6 +127,10 @@ $calendar-content-border-consideration: 2px; min-height: 25px; } } + + .cal-day-number { + user-select: none; // Do not allow selecting/highlighting text accidentally + } } } @@ -134,6 +138,7 @@ $calendar-content-border-consideration: 2px; $color-config: theming.get-color-config($theme-config); $primary: map.get($color-config, 'primary'); $accent: map.get($color-config, 'accent'); + $warn: map.get($color-config, 'warn'); $background: map.get($color-config, 'background'); $foreground: map.get($color-config, 'foreground'); @@ -147,10 +152,19 @@ $calendar-content-border-consideration: 2px; $event-bg-color: theming.get-color-from-palette($primary, 800); $event-text-color: theming.get-color-from-palette($primary, '800-contrast'); $highlight-color: theming.get-color-from-palette($accent, 300); - $weekend-color: theming.get-color-from-palette($accent, 500); + $weekend-color: $text-color; // theming.get-color-from-palette($accent, 500); $badge-color: theming.get-color-from-palette($accent, 500); $current-time-marker-color: theming.get-color-from-palette($accent, 400); + $selected-color: theming.get-color-from-palette($primary, '500-contrast'); + $selected-background-color: transparentize(theming.get-color-from-palette($primary, 500), 0.4); + + $disabled-color: theming.get-color-from-palette($warn, 500); + $disabled-background-color: theming.get-color-from-palette($background, 'background'); + + $not-applicable-color: transparentize($text-color, 0.7); + $not-applicable-background-color: $background-color; + #{calendar.$cal-event-icon-color-var}: $event-icon-color; // text/border color #{calendar.$cal-event-color-primary-var}: $event-text-color; // text/border color #{calendar.$cal-event-color-secondary-var}: $event-bg-color; // event background color @@ -158,10 +172,16 @@ $calendar-content-border-consideration: 2px; #{calendar.$cal-bg-primary-var}: $background-color; #{calendar.$cal-bg-secondary-var}: $hover-color; #{calendar.$cal-bg-active-var}: $hover-color; + #{calendar.$cal-bg-selected-var}: $selected-background-color; + #{calendar.$cal-bg-disabled-var}: $disabled-background-color; + #{calendar.$cal-bg-not-applicable-var}: $not-applicable-background-color; #{calendar.$cal-bg-highlight-var}: rgba($highlight-color, 0.3); #{calendar.$cal-today-bg-var}: $hover-color; #{calendar.$cal-weekend-color-var}: $weekend-color; #{calendar.$cal-badge-color-var}: $badge-color; + #{calendar.$cal-selected-var}: $selected-color; + #{calendar.$cal-disabled-var}: $disabled-color; + #{calendar.$cal-not-applicable-var}: $not-applicable-color; #{calendar.$cal-current-time-marker-color-var}: $current-time-marker-color; #{calendar.$cal-white-var}: #fff; #{calendar.$cal-gray-var}: #555; diff --git a/packages/dbx-web/src/lib/extension/calendar/style/_variables.scss b/packages/dbx-web/src/lib/extension/calendar/style/_variables.scss index 86ede7b19..1f666dd7a 100644 --- a/packages/dbx-web/src/lib/extension/calendar/style/_variables.scss +++ b/packages/dbx-web/src/lib/extension/calendar/style/_variables.scss @@ -7,9 +7,15 @@ $cal-bg-secondary-var: --cal-bg-secondary; $cal-bg-active-var: --cal-bg-active; $cal-bg-active-dragover-var: --cal-bg-active-dragover; $cal-bg-highlight-var: --cal-bg-highlight; +$cal-bg-selected-var: --cal-bg-selected; +$cal-bg-disabled-var: --cal-bg-disabled; +$cal-bg-not-applicable-var: --cal-bg-not-applicable; $cal-today-bg-var: --cal-today-bg; $cal-weekend-color-var: --cal-weekend-color; $cal-badge-color-var: --cal-badge-color; +$cal-selected-var: --cal-selected; +$cal-disabled-var: --cal-disabled; +$cal-not-applicable-var: --cal-not-applicable; $cal-current-time-marker-color-var: --cal-current-time-marker-color; $cal-white-var: --cal-white; $cal-gray-var: --cal-gray; @@ -25,9 +31,15 @@ $cal-bg-secondary: var($cal-bg-secondary-var); $cal-bg-active: var($cal-bg-active-var); $cal-bg-active-dragover: var($cal-bg-active-dragover-var); $cal-bg-highlight: var($cal-bg-highlight-var); +$cal-bg-selected-color: var($cal-bg-selected-var); +$cal-bg-disabled-color: var($cal-bg-disabled-var); +$cal-bg-not-applicable-color: var($cal-bg-not-applicable-var); $cal-today-bg: var($cal-today-bg-var); $cal-weekend-color: var($cal-weekend-color-var); $cal-badge-color: var($cal-badge-color-var); +$cal-selected-color: var($cal-selected-var); +$cal-disabled-color: var($cal-disabled-var); +$cal-not-applicable-color: var($cal-not-applicable-var); $cal-current-time-marker-color: var($cal-current-time-marker-color-var); $cal-white: var($cal-white-var); $cal-gray: var($cal-gray-var); @@ -53,6 +65,12 @@ $cal-vars: map-merge( bg-secondary: $cal-bg-secondary, // the color used when hovering over cells and headers bg-active: $cal-bg-active, + // the color use when a cell is selected + bg-selected-color: $cal-bg-selected-color, + // the color use when a cell is disabled + bg-disabled-color: $cal-bg-disabled-color, + // the color use when a cell is not applicable + bg-not-applicable-color: $cal-bg-not-applicable-color, // the color used when hovering over cells and headers bg-highlight: $cal-bg-highlight, // the background color to mark today in the week view header @@ -61,6 +79,12 @@ $cal-vars: map-merge( weekend-color: $cal-weekend-color, // the badge background color on the month view badge-color: $cal-badge-color, + // the selected color on the month view + selected-color: $cal-selected-color, + // the disabled color on the month view + disabled-color: $cal-disabled-color, + // the not applicable color on the month view + not-applicable-color: $cal-not-applicable-color, // the current time marker color on the week and day view current-time-marker-color: $cal-current-time-marker-color, // a standard white color used for tooltip text and month view event titles diff --git a/packages/dbx-web/src/lib/extension/calendar/style/month/calendar-month-view.scss b/packages/dbx-web/src/lib/extension/calendar/style/month/calendar-month-view.scss index 8c9ce7595..40e5e50e7 100644 --- a/packages/dbx-web/src/lib/extension/calendar/style/month/calendar-month-view.scss +++ b/packages/dbx-web/src/lib/extension/calendar/style/month/calendar-month-view.scss @@ -217,5 +217,35 @@ background-color: map-get($theme, gray); box-shadow: map-get($theme, gradient); } + + .cal-day-cell.cal-day-selected, + .cal-day-cell.cal-weekend.cal-day-selected { + color: map-get($theme, selected-color) !important; + background-color: map-get($theme, bg-selected-color) !important; + + .cal-day-number { + color: map-get($theme, selected-color) !important; + } + } + + .cal-day-cell.cal-day-disabled, + .cal-day-cell.cal-weekend.cal-day-disabled { + color: map-get($theme, disabled-color) !important; + background-color: map-get($theme, bg-disabled-color) !important; + + .cal-day-number { + color: map-get($theme, disabled-color) !important; + } + } + + .cal-day-cell.cal-day-not-applicable, + .cal-day-cell.cal-weekend.cal-day-not-applicable { + color: map-get($theme, not-applicable-color) !important; + background-color: map-get($theme, bg-not-applicable-color) !important; + + .cal-day-number { + color: map-get($theme, not-applicable-color) !important; + } + } } } diff --git a/packages/dbx-web/src/lib/interaction/dialog/_dialog.scss b/packages/dbx-web/src/lib/interaction/dialog/_dialog.scss index 7e12b81b2..713a9369f 100644 --- a/packages/dbx-web/src/lib/interaction/dialog/_dialog.scss +++ b/packages/dbx-web/src/lib/interaction/dialog/_dialog.scss @@ -16,6 +16,24 @@ $max-dialog-width: 90vw; width: $max-dialog-width; } } + + .dbx-dialog-content-close { + display: block; + height: 8px; + + > .mat-icon-button { + float: right; + margin-top: -15px; + margin-right: -15px; + } + } + + .dbx-dialog-content-footer { + margin-top: 8px; + display: flex; + align-items: center; + justify-content: center; + } } @mixin color($theme-config) { diff --git a/packages/dbx-web/src/lib/interaction/dialog/dialog.content.close.component.ts b/packages/dbx-web/src/lib/interaction/dialog/dialog.content.close.component.ts new file mode 100644 index 000000000..d2bc11f89 --- /dev/null +++ b/packages/dbx-web/src/lib/interaction/dialog/dialog.content.close.component.ts @@ -0,0 +1,26 @@ +import { Component, EventEmitter, OnDestroy, Output } from '@angular/core'; + +/** + * Component used to show a close button at the top of a dialog, floating in a corner. + */ +@Component({ + selector: 'dbx-dialog-content-close', + template: ` + + `, + host: { + class: 'dbx-dialog-content-close' + } +}) +export class DbxDialogContentCloseComponent implements OnDestroy { + @Output() + readonly close = new EventEmitter(); + + closeClicked() { + this.close.emit(undefined); + } + + ngOnDestroy(): void { + this.close.complete(); + } +} diff --git a/packages/dbx-web/src/lib/interaction/dialog/dialog.content.footer.component.ts b/packages/dbx-web/src/lib/interaction/dialog/dialog.content.footer.component.ts new file mode 100644 index 000000000..f69e44c4f --- /dev/null +++ b/packages/dbx-web/src/lib/interaction/dialog/dialog.content.footer.component.ts @@ -0,0 +1,29 @@ +import { Component, Directive, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; + +/** + * Component used to show a close button at the bottom of a dialog. + */ +@Component({ + selector: 'dbx-dialog-content-footer', + template: ` + + `, + host: { + class: 'dbx-dialog-content-footer' + } +}) +export class DbxDialogContentFooterComponent implements OnDestroy { + @Input() + closeText = 'Close'; + + @Output() + readonly close = new EventEmitter(); + + closeClicked() { + this.close.emit(undefined); + } + + ngOnDestroy(): void { + this.close.complete(); + } +} diff --git a/packages/dbx-web/src/lib/interaction/dialog/dialog.module.ts b/packages/dbx-web/src/lib/interaction/dialog/dialog.module.ts index dc1058649..1cedd8caf 100644 --- a/packages/dbx-web/src/lib/interaction/dialog/dialog.module.ts +++ b/packages/dbx-web/src/lib/interaction/dialog/dialog.module.ts @@ -3,13 +3,17 @@ import { NgModule } from '@angular/core'; import { DbxDialogContentDirective } from './dialog.content.component'; import { DbxStyleLayoutModule } from '../../layout/style/style.layout.module'; import { DbxActionDialogDirective } from './dialog.action.directive'; +import { DbxDialogContentFooterComponent } from './dialog.content.footer.component'; +import { MatButtonModule } from '@angular/material/button'; +import { DbxDialogContentCloseComponent } from './dialog.content.close.component'; +import { MatIconModule } from '@angular/material/icon'; /** * Module for block components. */ @NgModule({ - imports: [CommonModule, DbxStyleLayoutModule], - declarations: [DbxDialogContentDirective, DbxActionDialogDirective], - exports: [DbxDialogContentDirective, DbxActionDialogDirective] + imports: [CommonModule, DbxStyleLayoutModule, MatButtonModule, MatIconModule], + declarations: [DbxDialogContentDirective, DbxActionDialogDirective, DbxDialogContentFooterComponent, DbxDialogContentCloseComponent], + exports: [DbxDialogContentDirective, DbxActionDialogDirective, DbxDialogContentFooterComponent, DbxDialogContentCloseComponent] }) export class DbxDialogInteractionModule {} diff --git a/packages/dbx-web/src/lib/interaction/dialog/index.ts b/packages/dbx-web/src/lib/interaction/dialog/index.ts index 2940a6baa..10535cd94 100644 --- a/packages/dbx-web/src/lib/interaction/dialog/index.ts +++ b/packages/dbx-web/src/lib/interaction/dialog/index.ts @@ -1,4 +1,6 @@ export * from './abstract.dialog.directive'; +export * from './dialog.content.close.component'; +export * from './dialog.content.footer.component'; export * from './dialog.content.component'; export * from './dialog.action.directive'; export * from './dialog.module'; diff --git a/packages/dbx-web/src/lib/layout/style/_style.scss b/packages/dbx-web/src/lib/layout/style/_style.scss index 848a1820b..7755ae8aa 100644 --- a/packages/dbx-web/src/lib/layout/style/_style.scss +++ b/packages/dbx-web/src/lib/layout/style/_style.scss @@ -43,6 +43,11 @@ display: inline; } + .d-flex, + .dbx-flex { + display: flex; + } + .d-iflex, .dbx-iflex { display: inline-flex; diff --git a/packages/util/fetch/src/lib/error.spec.ts b/packages/util/fetch/src/lib/error.spec.ts index 559548438..aaf699a0f 100644 --- a/packages/util/fetch/src/lib/error.spec.ts +++ b/packages/util/fetch/src/lib/error.spec.ts @@ -8,6 +8,8 @@ const testFetch: FetchService = fetchService({ makeRequest: (x, y) => new Request(x as RequestInfo, y as RequestInit) as any }); +jest.setTimeout(10000); + describe('requireOkResponse()', () => { const forbiddenUrl = 'https://components.dereekb.com/api/webhook'; diff --git a/packages/util/fetch/src/lib/url.spec.ts b/packages/util/fetch/src/lib/url.spec.ts index 5d3649968..b4f94a617 100644 --- a/packages/util/fetch/src/lib/url.spec.ts +++ b/packages/util/fetch/src/lib/url.spec.ts @@ -15,7 +15,7 @@ const queryParams = new URLSearchParams(queryParamsTuples); describe('isURL()', () => { it('should return true for a URL instance.', () => { const result = isURL(url); - expect(result); + expect(result).toBe(true); }); it('should return false for a string.', () => { const result = isURL(urlString); @@ -26,7 +26,7 @@ describe('isURL()', () => { describe('isURLSearchParams()', () => { it('should return true for a URLSearchParams instance.', () => { const result = isURLSearchParams(queryParams); - expect(result); + expect(result).toBe(true); }); it('should return false for a url string.', () => { const result = isURLSearchParams(urlString); diff --git a/packages/util/src/lib/array/array.number.spec.ts b/packages/util/src/lib/array/array.number.spec.ts new file mode 100644 index 000000000..afca85a3a --- /dev/null +++ b/packages/util/src/lib/array/array.number.spec.ts @@ -0,0 +1,14 @@ +import { range } from './array.number'; + +describe('range()', () => { + it('should create a range with negative values.', () => { + const result = range({ end: -5 }); + expect(result.length).toBe(5); + + expect(result[0]).toBe(0); + expect(result[1]).toBe(-1); + expect(result[2]).toBe(-2); + expect(result[3]).toBe(-3); + expect(result[4]).toBe(-4); + }); +}); diff --git a/packages/util/src/lib/array/array.number.ts b/packages/util/src/lib/array/array.number.ts index f97ec88da..fafd58ecb 100644 --- a/packages/util/src/lib/array/array.number.ts +++ b/packages/util/src/lib/array/array.number.ts @@ -69,8 +69,14 @@ export function range(input: RangeInput, inputEnd?: number): number[] { end = input.end; } - for (let i = start; i < end; i += 1) { - range.push(i); + if (end >= start) { + for (let i = start; i < end; i += 1) { + range.push(i); + } + } else { + for (let i = start; i > end; i -= 1) { + range.push(i); + } } return range; diff --git a/packages/util/src/lib/date/week.spec.ts b/packages/util/src/lib/date/week.spec.ts index b7f35740c..99ca803f7 100644 --- a/packages/util/src/lib/date/week.spec.ts +++ b/packages/util/src/lib/date/week.spec.ts @@ -1,3 +1,4 @@ +import { range } from '../array'; import { Day, getDayOffset, getNextDay, getPreviousDay } from './week'; describe('getDayOffset()', () => { @@ -40,6 +41,30 @@ describe('getNextDay()', () => { }); it('should return the next days for the number of days input.', () => { + expect(getNextDay(Day.FRIDAY, 9)).toBe(Day.SUNDAY); + expect(getNextDay(Day.FRIDAY, 8)).toBe(Day.SATURDAY); + expect(getNextDay(Day.FRIDAY, 7)).toBe(Day.FRIDAY); + expect(getNextDay(Day.FRIDAY, 6)).toBe(Day.THURSDAY); + expect(getNextDay(Day.FRIDAY, 5)).toBe(Day.WEDNESDAY); expect(getNextDay(Day.FRIDAY, 4)).toBe(Day.TUESDAY); + expect(getNextDay(Day.FRIDAY, 3)).toBe(Day.MONDAY); + expect(getNextDay(Day.FRIDAY, 2)).toBe(Day.SUNDAY); + expect(getNextDay(Day.FRIDAY, 1)).toBe(Day.SATURDAY); + expect(getNextDay(Day.FRIDAY, 0)).toBe(Day.FRIDAY); + }); + + it('should return the next days for a negative number of days input.', () => { + expect(getNextDay(Day.FRIDAY, 0)).toBe(Day.FRIDAY); + expect(getNextDay(Day.FRIDAY, -1)).toBe(Day.THURSDAY); + expect(getNextDay(Day.FRIDAY, -2)).toBe(Day.WEDNESDAY); + expect(getNextDay(Day.FRIDAY, -3)).toBe(Day.TUESDAY); + expect(getNextDay(Day.FRIDAY, -4)).toBe(Day.MONDAY); + expect(getNextDay(Day.FRIDAY, -5)).toBe(Day.SUNDAY); + expect(getNextDay(Day.FRIDAY, -6)).toBe(Day.SATURDAY); + expect(getNextDay(Day.FRIDAY, -7)).toBe(Day.FRIDAY); + expect(getNextDay(Day.FRIDAY, -8)).toBe(Day.THURSDAY); + expect(getNextDay(Day.FRIDAY, -9)).toBe(Day.WEDNESDAY); + expect(getNextDay(Day.FRIDAY, -10)).toBe(Day.TUESDAY); + expect(getNextDay(Day.FRIDAY, -11)).toBe(Day.MONDAY); }); }); diff --git a/packages/util/src/lib/date/week.ts b/packages/util/src/lib/date/week.ts index a21c614b2..4f034ddbc 100644 --- a/packages/util/src/lib/date/week.ts +++ b/packages/util/src/lib/date/week.ts @@ -1,3 +1,5 @@ +import { Maybe } from '../value'; + export type Sunday = 0; export type Monday = 1; export type Tuesday = 2; @@ -36,6 +38,85 @@ export enum Day { SATURDAY = 6 } +/** + * Object containing the name of every day and whether they're true/false. + */ +export interface EnabledDays { + sunday: boolean; + monday: boolean; + tuesday: boolean; + wednesday: boolean; + thursday: boolean; + friday: boolean; + saturday: boolean; +} + +export function enabledDaysFromDaysOfWeek(input: Maybe>): EnabledDays { + const set = new Set(input); + + return { + sunday: set.has(Day.SUNDAY), + monday: set.has(Day.MONDAY), + tuesday: set.has(Day.TUESDAY), + wednesday: set.has(Day.WEDNESDAY), + thursday: set.has(Day.THURSDAY), + friday: set.has(Day.FRIDAY), + saturday: set.has(Day.SATURDAY) + }; +} + +export function daysOfWeekFromEnabledDays(input: Maybe): Day[] { + const daysOfWeek: Day[] = []; + + if (input) { + if (input.sunday) { + daysOfWeek.push(Day.SUNDAY); + } + + if (input.monday) { + daysOfWeek.push(Day.MONDAY); + } + + if (input.tuesday) { + daysOfWeek.push(Day.TUESDAY); + } + + if (input.wednesday) { + daysOfWeek.push(Day.WEDNESDAY); + } + + if (input.thursday) { + daysOfWeek.push(Day.THURSDAY); + } + + if (input.friday) { + daysOfWeek.push(Day.FRIDAY); + } + + if (input.saturday) { + daysOfWeek.push(Day.SATURDAY); + } + } + + return daysOfWeek; +} + +/** + * Returns an array of strinsg with each day of the week named. + * + * @returns + */ +export function getDaysOfWeekNames(sundayFirst = true) { + const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const sunday = 'Sunday'; + + if (sundayFirst) { + return [sunday, ...days]; + } else { + return [...days, sunday]; + } +} + export function getDayTomorrow(day: DayOfWeek): DayOfWeek { return getNextDay(day, 1); } @@ -61,5 +142,11 @@ export function getPreviousDay(day: DayOfWeek, days: number = 1): DayOfWeek { } export function getNextDay(day: DayOfWeek, days: number = 1): DayOfWeek { - return Math.abs((day + days) % 7) as DayOfWeek; + let result = ((day + days) % 7) as DayOfWeek; + + if (result < 0) { + result = (7 + result) as DayOfWeek; + } + + return result; } diff --git a/packages/util/src/lib/number/dollar.spec.ts b/packages/util/src/lib/number/dollar.spec.ts index 6690eb2b6..146fe651a 100644 --- a/packages/util/src/lib/number/dollar.spec.ts +++ b/packages/util/src/lib/number/dollar.spec.ts @@ -4,13 +4,13 @@ describe('isDollarAmountString()', () => { it('should return true for numbers without a decimal', () => { const string = '100'; const result = isDollarAmountString(string); - expect(result); + expect(result).toBe(true); }); it('should return true for numbers with two decimal places', () => { const string = '100.51'; const result = isDollarAmountString(string); - expect(result); + expect(result).toBe(true); }); it('should return false for numbers with a decimal period', () => { diff --git a/packages/util/src/lib/number/number.ts b/packages/util/src/lib/number/number.ts index db1fc5bbf..a3b594b2e 100644 --- a/packages/util/src/lib/number/number.ts +++ b/packages/util/src/lib/number/number.ts @@ -1,3 +1,4 @@ +import { MinAndMax, minAndMaxFunction, MinAndMaxFunctionResult, SortCompareFunction } from '../sort'; import { Maybe } from '../value/maybe.type'; /** @@ -110,3 +111,9 @@ export function sumOfIntegersBetween(from: number, to: number): number { const sumOfIntegers = (sum / 2) * totalNumbers; return sumOfIntegers; } + +export const sortCompareNumberFunction: SortCompareFunction = (a, b) => a - b; + +export function minAndMaxNumber(values: Iterable): MinAndMaxFunctionResult { + return minAndMaxFunction(sortCompareNumberFunction)(values); +} diff --git a/packages/util/src/lib/set/set.spec.ts b/packages/util/src/lib/set/set.spec.ts index 974085587..c1e3e4d63 100644 --- a/packages/util/src/lib/set/set.spec.ts +++ b/packages/util/src/lib/set/set.spec.ts @@ -1,4 +1,4 @@ -import { setIncludes, ReadKeyFunction } from '@dereekb/util'; +import { setIncludes, ReadKeyFunction, setsAreEquivalent } from '@dereekb/util'; import { firstValueFromIterable } from '../iterable'; import { asSet, containsAnyValue, containsAnyValueFromSet, findValuesFrom, hasDifferentValues, setContainsAllValues, setContainsAnyValue } from './set'; @@ -250,3 +250,17 @@ describe('setContainsAllValues', () => { expect(result).toBe(false); }); }); + +describe('setsAreEquivalent()', () => { + it('should return true if the sets have the same values.', () => { + const values = [0, 1, 2]; + const result = setsAreEquivalent(new Set(values), new Set(values)); + expect(result).toBe(true); + }); + + it('should return false if one set is a subset of another set but not the same', () => { + const values = [0, 1, 2]; + const result = setsAreEquivalent(new Set(values), new Set([0, 1])); + expect(result).toBe(false); + }); +}); diff --git a/packages/util/src/lib/set/set.ts b/packages/util/src/lib/set/set.ts index 287863604..ea954e660 100644 --- a/packages/util/src/lib/set/set.ts +++ b/packages/util/src/lib/set/set.ts @@ -274,6 +274,27 @@ export function setContainsAllValues(valuesSet: Set, valuesToFind: Iterabl return valuesSet ? Array.from(asIterable(valuesToFind)).findIndex((x) => !valuesSet.has(x)) == -1 : false; } +/** + * Returns true if both iterables are defined (or are both null/undefined) and have the same values exactly. + * + * @param a + * @param b + * @returns + */ +export function iterablesAreSetEquivalent(a: Maybe>, b: Maybe>): boolean { + return a && b ? setsAreEquivalent(new Set(a), new Set(b)) : a == b; +} + +/** + * Returns true if both sets are defined (or are both null/undefined) and have the same values exactly. + * + * @param a + * @param b + */ +export function setsAreEquivalent(a: Maybe>, b: Maybe>): boolean { + return a && b ? a.size === b.size && symmetricDifferenceArrayBetweenSets(a, b).length === 0 : a == b; +} + // MARK: Compat /** * @deprecated use symmetricDifferenceArray diff --git a/packages/util/src/lib/sort.spec.ts b/packages/util/src/lib/sort.spec.ts new file mode 100644 index 000000000..bcf58dda9 --- /dev/null +++ b/packages/util/src/lib/sort.spec.ts @@ -0,0 +1,21 @@ +import { minAndMaxFunction, StringKeyPropertyKeys } from '@dereekb/util'; +import { AllCommaSeparatedKeysOfObject, CommaSeparatedKeyCombinationsOfObject, HasThreeCharacters, HasThreeOrMoreCharacters, IsSingleCharacter, KeyAsString, KeyCanBeString, MergeReplace, OrderedCommaSeparatedKeysOfObject, Replace, ReplaceType, StringConcatenation, StringKeyProperties } from './type'; + +describe('minAndMaxFunction()', () => { + describe('function', () => { + const fn = minAndMaxFunction((a, b) => a - b); + + it('should return undefined if no values are passed to the function.', () => { + const result = fn([]); + expect(result).toBe(null); + }); + + it('should return the min and max values', () => { + const min = 0; + const max = 5; + const result = fn([min, 1, 2, 3, 4, max]); + expect(result?.min).toBe(min); + expect(result?.max).toBe(max); + }); + }); +}); diff --git a/packages/util/src/lib/sort.ts b/packages/util/src/lib/sort.ts index 59af23599..42a9a3ac0 100644 --- a/packages/util/src/lib/sort.ts +++ b/packages/util/src/lib/sort.ts @@ -1,3 +1,6 @@ +import { firstValueFromIterable, forEachInIterable } from './iterable/iterable'; +import { Maybe } from './value/maybe.type'; + export type SortingOrder = 'asc' | 'desc'; export const SORT_VALUE_LESS_THAN: SortComparisonNumber = -1; @@ -50,3 +53,48 @@ export function reverseCompareFn(compareFn: SortCompareFunction): SortComp export function compareFnOrder(ascendingCompareFn: AscendingSortCompareFunction, order: SortingOrder = 'asc'): SortCompareFunction { return order === 'asc' ? ascendingCompareFn : reverseCompareFn(ascendingCompareFn); } + +export interface MinAndMax { + min: T; + max: T; +} + +export type MinAndMaxFunctionResult = MinAndMax | null; + +/** + * Returns the min and maximum value from the input values. + * + * If the input iterable is empty, then returns undefined. + */ +export type MinAndMaxFunction = (values: Iterable) => MinAndMaxFunctionResult; + +/** + * Creates a MinAndMaxFunction using the input compare. + * + * @param compare + */ +export function minAndMaxFunction(compareFn: SortCompareFunction): MinAndMaxFunction { + return (values: Iterable) => { + let min: Maybe = firstValueFromIterable(values) ?? undefined; + let max: Maybe = min; + + if (min != null && max != null) { + forEachInIterable(values, (x) => { + const compareMin = compareFn(x, min as T); + const compareMax = compareFn(x, max as T); + + if (compareMin < 0) { + min = x; + } + + if (compareMax > 0) { + max = x; + } + }); + + return { min, max }; + } else { + return null; + } + }; +} diff --git a/packages/util/src/lib/value/address.spec.ts b/packages/util/src/lib/value/address.spec.ts index 60319b16a..43d8c8913 100644 --- a/packages/util/src/lib/value/address.spec.ts +++ b/packages/util/src/lib/value/address.spec.ts @@ -3,14 +3,14 @@ import { isUsStateCodeString } from './address'; describe('isUsStateCodeString()', () => { it('should return true for upper case state codes.', () => { const result = isUsStateCodeString('TX'); - expect(result); + expect(result).toBe(true); }); it('should return false for invalid state codes.', () => { const result = isUsStateCodeString('XX'); - expect(result); + expect(result).toBe(false); }); it('should return false for lower case state codes.', () => { const result = isUsStateCodeString('tx'); - expect(result); + expect(result).toBe(false); }); });