Browse Source

feat(admin-ui): Create cross-browser datetime picker component

This implements the basic datetime picker. Currently does not support disabled date ranges or seconds selection.
Relates to #181
Michael Bromley 6 years ago
parent
commit
78a713c379

+ 1 - 0
packages/admin-ui/package.json

@@ -43,6 +43,7 @@
     "apollo-upload-client": "^11.0.0",
     "chokidar": "^3.0.2",
     "core-js": "^3.1.3",
+    "dayjs": "^1.8.16",
     "fs-extra": "^8.1.0",
     "graphql": "^14.3.1",
     "graphql-tag": "^2.10.1",

+ 2 - 14
packages/admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.html

@@ -42,22 +42,10 @@
         />
     </vdr-form-field>
     <vdr-form-field [label]="'marketing.starts-at' | translate" for="startsAt">
-        <input
-            type="datetime-local"
-            name="startsAt"
-            [value]="detailForm.get('startsAt')?.value | date: 'yyyy-MM-ddTHH:mm:ss'"
-            (change)="updateDateTime(detailForm.get('startsAt')!, $event)"
-            [readonly]="!('UpdatePromotion' | hasPermission)"
-        />
+        <vdr-datetime-picker formControlName="startsAt"></vdr-datetime-picker>
     </vdr-form-field>
     <vdr-form-field [label]="'marketing.ends-at' | translate" for="endsAt">
-        <input
-            type="datetime-local"
-            name="endsAt"
-            [value]="detailForm.get('endsAt')?.value | date: 'yyyy-MM-ddTHH:mm:ss'"
-            (change)="updateDateTime(detailForm.get('endsAt')!, $event)"
-            [readonly]="!('UpdatePromotion' | hasPermission)"
-        />
+        <vdr-datetime-picker formControlName="endsAt"></vdr-datetime-picker>
     </vdr-form-field>
     <vdr-form-field [label]="'marketing.coupon-code' | translate" for="couponCode">
         <input

+ 0 - 8
packages/admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts

@@ -136,14 +136,6 @@ export class PromotionDetailComponent extends BaseDetailComponent<Promotion.Frag
         return this.detailForm.get(key) as FormArray;
     }
 
-    // TODO: Remove this once a dedicated cross-browser datetime picker
-    // exists. See https://github.com/vendure-ecommerce/vendure/issues/181
-    updateDateTime(formControl: AbstractControl, event: Event) {
-        const value = (event.target as HTMLInputElement).value;
-        formControl.setValue(value ? new Date(value).toISOString() : null, { emitEvent: true });
-        formControl.parent.markAsDirty();
-    }
-
     create() {
         if (!this.detailForm.dirty) {
             return;

+ 6 - 7
packages/admin-ui/src/app/shared/components/custom-field-control/custom-field-control.component.html

@@ -57,15 +57,14 @@
             [readonly]="readonly"
         />
     </clr-toggle-wrapper>
-    <input
+    <vdr-datetime-picker
         *ngIf="customField.type === 'datetime'"
-        type="datetime-local"
+        [id]="customField.name"
+        [formControl]="formGroup.get(customField.name)"
         [min]="min"
         [max]="max"
-        [step]="step"
-        [id]="customField.name"
-        [value]="formGroup.get(customField.name).value | date: 'yyyy-MM-ddTHH:mm:ss'"
-        (change)="updateDateTime(formGroup.get(customField.name), $event)"
         [readonly]="readonly"
-    />
+    >
+
+    </vdr-datetime-picker>
 </ng-template>

+ 0 - 6
packages/admin-ui/src/app/shared/components/custom-field-control/custom-field-control.component.ts

@@ -140,12 +140,6 @@ export class CustomFieldControlComponent implements OnInit, OnDestroy, AfterView
         }
     }
 
-    updateDateTime(formControl: FormControl, event: Event) {
-        const value = (event.target as HTMLInputElement).value;
-        formControl.setValue(value ? new Date(value).toISOString() : null, { emitEvent: true });
-        formControl.parent.markAsDirty();
-    }
-
     getLabel(defaultLabel: string, label?: LocalizedString[] | null): string {
         if (label) {
             const match = label.find(l => l.languageCode === this.uiLanguageCode);

+ 23 - 0
packages/admin-ui/src/app/shared/components/datetime-picker/constants.ts

@@ -0,0 +1,23 @@
+import { DayOfWeek } from '@vendure/admin-ui/src/app/shared/components/datetime-picker/types';
+
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+
+export const dayOfWeekIndex: { [day in DayOfWeek]: number } = {
+    sun: 0,
+    mon: 1,
+    tue: 2,
+    wed: 3,
+    thu: 4,
+    fri: 5,
+    sat: 6,
+};
+
+export const weekDayNames = [
+    _('datetime.weekday-su'),
+    _('datetime.weekday-mo'),
+    _('datetime.weekday-tu'),
+    _('datetime.weekday-we'),
+    _('datetime.weekday-th'),
+    _('datetime.weekday-fr'),
+    _('datetime.weekday-sa'),
+];

+ 119 - 0
packages/admin-ui/src/app/shared/components/datetime-picker/datetime-picker.component.html

@@ -0,0 +1,119 @@
+<div class="input-wrapper">
+    <input
+        readonly
+        [ngModel]="selected$ | async | date: 'medium'"
+        class="selected-datetime"
+        (keydown.enter)="dropdownComponent.toggleOpen()"
+        (keydown.space)="dropdownComponent.toggleOpen()"
+        #datetimeInput
+    />
+    <button class="clear-value-button btn" [class.visible]="!disabled && !readonly && (selected$ | async)" (click)="clearValue()">
+        <clr-icon shape="times"></clr-icon>
+    </button>
+</div>
+<vdr-dropdown #dropdownComponent>
+    <button class="btn btn-outline calendar-button" vdrDropdownTrigger [disabled]="readonly || disabled">
+        <clr-icon shape="calendar"></clr-icon>
+    </button>
+    <vdr-dropdown-menu>
+        <div class="datetime-picker" *ngIf="current$ | async as currentView" (keydown.escape)="closeDatepicker()">
+            <div class="controls">
+                <div class="selects">
+                    <div class="month-select">
+                        <select
+                            clrSelect
+                            name="month"
+                            [ngModel]="currentView.month"
+                            (change)="setMonth($event)"
+                        >
+                            <option [value]="1">{{ 'datetime.month-jan' | translate }}</option>
+                            <option [value]="2">{{ 'datetime.month-feb' | translate }}</option>
+                            <option [value]="3">{{ 'datetime.month-mar' | translate }}</option>
+                            <option [value]="4">{{ 'datetime.month-apr' | translate }}</option>
+                            <option [value]="5">{{ 'datetime.month-may' | translate }}</option>
+                            <option [value]="6">{{ 'datetime.month-jun' | translate }}</option>
+                            <option [value]="7">{{ 'datetime.month-jul' | translate }}</option>
+                            <option [value]="8">{{ 'datetime.month-aug' | translate }}</option>
+                            <option [value]="9">{{ 'datetime.month-sep' | translate }}</option>
+                            <option [value]="10">{{ 'datetime.month-oct' | translate }}</option>
+                            <option [value]="11">{{ 'datetime.month-nov' | translate }}</option>
+                            <option [value]="12">{{ 'datetime.month-dec' | translate }}</option>
+                        </select>
+                    </div>
+                    <div class="year-select">
+                        <select
+                            clrSelect
+                            name="month"
+                            [ngModel]="currentView.year"
+                            (change)="setYear($event)"
+                        >
+                            <option *ngFor="let year of years" [value]="year">{{ year }}</option>
+                        </select>
+                    </div>
+                </div>
+                <div class="control-buttons">
+                    <button
+                        class="btn btn-link btn-sm"
+                        (click)="prevMonth()"
+                        [title]="'common.view-previous-month' | translate"
+                    >
+                        <clr-icon shape="caret" dir="left"></clr-icon>
+                    </button>
+                    <button class="btn btn-link btn-sm" (click)="selectToday()" [title]="'common.select-today' | translate">
+                        <clr-icon shape="event"></clr-icon>
+                    </button>
+                    <button
+                        class="btn btn-link btn-sm"
+                        (click)="nextMonth()"
+                        [title]="'common.view-next-month' | translate"
+                    >
+                        <clr-icon shape="caret" dir="right"></clr-icon>
+                    </button>
+                </div>
+            </div>
+            <table class="calendar-table" #calendarTable tabindex="0" (keydown)="handleCalendarKeydown($event)">
+                <thead>
+                <tr>
+                    <td *ngFor="let weekdayName of weekdays">
+                        {{ weekdayName | translate }}
+                    </td>
+                </tr>
+                </thead>
+                <tbody>
+                <tr *ngFor="let week of calendarView$ | async">
+                    <td
+                        *ngFor="let day of week"
+                        class="day-cell"
+                        [class.selected]="day.selected"
+                        [class.today]="day.isToday"
+                        [class.viewing]="day.isViewing"
+                        [class.current-month]="day.inCurrentMonth"
+                        [class.disabled]="day.disabled"
+                        (keydown.enter)="selectDay(day)"
+                        (click)="selectDay(day)"
+                    >
+                        {{ day.dayOfMonth }}
+                    </td>
+                </tr>
+                </tbody>
+            </table>
+            <div class="time-picker">
+                <span class="flex-spacer"> {{ 'datetime.time' | translate }}: </span>
+                <select clrSelect name="hour" [ngModel]="selectedHours$ | async" (change)="setHour($event)">
+                    <option *ngFor="let hour of hours" [value]="hour">{{ hour | number: '2.0-0' }}</option>
+                </select>
+                <span>:</span>
+                <select
+                    clrSelect
+                    name="hour"
+                    [ngModel]="selectedMinutes$ | async"
+                    (change)="setMinute($event)"
+                >
+                    <option *ngFor="let minute of minutes" [value]="minute">{{
+                        minute | number: '2.0-0'
+                        }}</option>
+                </select>
+            </div>
+        </div>
+    </vdr-dropdown-menu>
+</vdr-dropdown>

+ 95 - 0
packages/admin-ui/src/app/shared/components/datetime-picker/datetime-picker.component.scss

@@ -0,0 +1,95 @@
+@import "variables";
+
+:host {
+    display: flex;
+    width: 100%;
+}
+
+.input-wrapper {
+    flex: 1;
+    display: flex;
+}
+
+input.selected-datetime {
+    flex: 1;
+    border-top-right-radius: 0 !important;
+    border-bottom-right-radius: 0 !important;
+    border-right: none !important;
+}
+
+.clear-value-button {
+    margin: 0;
+    border-radius: 0;
+    border-left: none;
+    border-color: $color-grey-300;
+    background-color: white;
+    color: $color-grey-500;
+    display: none;
+    &.visible {
+        display: block;
+    }
+}
+
+.calendar-button {
+    margin: 0;
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+}
+
+.datetime-picker {
+    margin: 0 12px;
+}
+table.calendar-table {
+    padding: 6px;
+    &:focus {
+        outline: 1px solid $color-primary-500;
+        box-shadow: 0 0 1px 2px $color-primary-100;
+    }
+    td {
+        width: 24px;
+        text-align: center;
+        border: 1px solid transparent;
+        user-select: none;
+    }
+    .day-cell {
+        background-color: $color-grey-200;
+        color: $color-grey-500;
+
+        cursor: pointer;
+        transition: background-color 0.1s;
+        &.current-month {
+            background-color: white;
+            color: $color-grey-800;
+        }
+        &.selected {
+            background-color: $color-primary-500;
+            color: white;
+        }
+        &.viewing:not(.selected) {
+            background-color: $color-primary-200;
+        }
+        &.today {
+            border: 1px solid $color-grey-400;
+        }
+        &:hover:not(.selected):not(.disabled) {
+            background-color: $color-primary-100;
+        }
+        &.disabled {
+            cursor: default;
+            color: $color-grey-300;
+        }
+    }
+}
+.selects {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 12px;
+}
+.control-buttons {
+    display: flex;
+}
+.time-picker {
+    display: flex;
+    align-items: baseline;
+    margin-top: 12px;
+}

+ 235 - 0
packages/admin-ui/src/app/shared/components/datetime-picker/datetime-picker.component.ts

@@ -0,0 +1,235 @@
+import {
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    ElementRef,
+    Input,
+    OnDestroy,
+    OnInit,
+    ViewChild,
+} from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { DropdownComponent } from '@vendure/admin-ui/src/app/shared/components/dropdown/dropdown.component';
+import { Observable, Subscription } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { dayOfWeekIndex, weekDayNames } from './constants';
+import { DatetimePickerService } from './datetime-picker.service';
+import { CalendarView, DayCell, DayOfWeek } from './types';
+
+export type CurrentView = {
+    date: Date;
+    month: number;
+    year: number;
+};
+
+@Component({
+    selector: 'vdr-datetime-picker',
+    templateUrl: './datetime-picker.component.html',
+    styleUrls: ['./datetime-picker.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    providers: [
+        DatetimePickerService,
+        {
+            provide: NG_VALUE_ACCESSOR,
+            useExisting: DatetimePickerComponent,
+            multi: true,
+        },
+    ],
+})
+export class DatetimePickerComponent implements ControlValueAccessor, AfterViewInit, OnInit, OnDestroy {
+    /**
+     * The range above and below the current year which is selectable from
+     * the year select control.
+     */
+    @Input() yearRange = 10;
+    /**
+     * The day that the week should start with in the calendar view.
+     */
+    @Input() weekStartDay: DayOfWeek = 'mon';
+    /**
+     * The granularity of the minutes time picker
+     */
+    @Input() timeGranularityInterval = 5;
+    /**
+     * The minimum date as an ISO string
+     */
+    @Input() min: string | null = null;
+    /**
+     * The maximum date as an ISO string
+     */
+    @Input() max: string | null = null;
+    /**
+     * Sets the readonly state
+     */
+    @Input() readonly = false;
+
+    @ViewChild('dropdownComponent', { static: true }) dropdownComponent: DropdownComponent;
+    @ViewChild('datetimeInput', { static: true }) datetimeInput: ElementRef<HTMLInputElement>;
+    @ViewChild('calendarTable', { static: false }) calendarTable: ElementRef<HTMLTableElement>;
+
+    disabled = false;
+    calendarView$: Observable<CalendarView>;
+    current$: Observable<CurrentView>;
+    selected$: Observable<Date | null>;
+    selectedHours$: Observable<number | null>;
+    selectedMinutes$: Observable<number | null>;
+    years: number[];
+    weekdays: string[] = [];
+    hours: number[];
+    minutes: number[];
+    private onChange: (val: any) => void;
+    private onTouch: () => void;
+    private subscription: Subscription;
+
+    constructor(
+        private changeDetectorRef: ChangeDetectorRef,
+        private datetimePickerService: DatetimePickerService,
+    ) {}
+
+    ngOnInit() {
+        this.datetimePickerService.setWeekStartingDay(this.weekStartDay);
+        this.datetimePickerService.setMin(this.min);
+        this.datetimePickerService.setMax(this.max);
+        this.populateYearsSelection();
+        this.populateWeekdays();
+        this.populateHours();
+        this.populateMinutes();
+        this.calendarView$ = this.datetimePickerService.calendarView$;
+        this.current$ = this.datetimePickerService.viewing$.pipe(
+            map(date => ({
+                date,
+                month: date.getMonth() + 1,
+                year: date.getFullYear(),
+            })),
+        );
+        this.selected$ = this.datetimePickerService.selected$;
+        this.selectedHours$ = this.selected$.pipe(map(date => date && date.getHours()));
+        this.selectedMinutes$ = this.selected$.pipe(map(date => date && date.getMinutes()));
+        this.subscription = this.datetimePickerService.selected$.subscribe(val => {
+            if (this.onChange) {
+                this.onChange(val == null ? val : val.toISOString());
+            }
+        });
+    }
+
+    ngAfterViewInit(): void {
+        this.dropdownComponent.onOpenChange(isOpen => {
+            if (isOpen) {
+                this.calendarTable.nativeElement.focus();
+            }
+        });
+    }
+
+    ngOnDestroy(): void {
+        if (this.subscription) {
+            this.subscription.unsubscribe();
+        }
+    }
+
+    registerOnChange(fn: any) {
+        this.onChange = fn;
+    }
+
+    registerOnTouched(fn: any) {
+        this.onTouch = fn;
+    }
+
+    setDisabledState(isDisabled: boolean) {
+        this.disabled = isDisabled;
+    }
+
+    writeValue(value: string | null) {
+        this.datetimePickerService.selectDatetime(value);
+    }
+
+    prevMonth() {
+        this.datetimePickerService.viewPrevMonth();
+    }
+
+    nextMonth() {
+        this.datetimePickerService.viewNextMonth();
+    }
+
+    selectToday() {
+        this.datetimePickerService.selectToday();
+    }
+
+    setYear(event: Event) {
+        const target = event.target as HTMLSelectElement;
+        this.datetimePickerService.viewYear(parseInt(target.value, 10));
+    }
+
+    setMonth(event: Event) {
+        const target = event.target as HTMLSelectElement;
+        this.datetimePickerService.viewMonth(parseInt(target.value, 10));
+    }
+
+    selectDay(day: DayCell) {
+        if (day.disabled) {
+            return;
+        }
+        day.select();
+    }
+
+    clearValue() {
+        this.datetimePickerService.selectDatetime(null);
+    }
+
+    handleCalendarKeydown(event: KeyboardEvent) {
+        switch (event.key) {
+            case 'ArrowDown':
+                return this.datetimePickerService.viewJumpDown();
+            case 'ArrowUp':
+                return this.datetimePickerService.viewJumpUp();
+            case 'ArrowRight':
+                return this.datetimePickerService.viewJumpRight();
+            case 'ArrowLeft':
+                return this.datetimePickerService.viewJumpLeft();
+            case 'Enter':
+                return this.datetimePickerService.selectViewed();
+        }
+    }
+
+    setHour(event: Event) {
+        const target = event.target as HTMLSelectElement;
+        this.datetimePickerService.selectHour(parseInt(target.value, 10));
+    }
+
+    setMinute(event: Event) {
+        const target = event.target as HTMLSelectElement;
+        this.datetimePickerService.selectMinute(parseInt(target.value, 10));
+    }
+
+    private closeDatepicker() {
+        this.dropdownComponent.toggleOpen();
+        this.datetimeInput.nativeElement.focus();
+    }
+
+    private populateYearsSelection() {
+        const currentYear = new Date().getFullYear();
+        this.years = Array.from({ length: this.yearRange * 2 + 1 }).map(
+            (_, i) => currentYear - this.yearRange + i,
+        );
+    }
+
+    private populateWeekdays() {
+        const weekStartDayIndex = dayOfWeekIndex[this.weekStartDay];
+        for (let i = 0; i < 7; i++) {
+            this.weekdays.push(weekDayNames[(i + weekStartDayIndex + 0) % 7]);
+        }
+    }
+
+    private populateHours() {
+        this.hours = Array.from({ length: 24 }).map((_, i) => i);
+    }
+
+    private populateMinutes() {
+        const minutes: number[] = [];
+        for (let i = 0; i < 60; i += this.timeGranularityInterval) {
+            minutes.push(i);
+        }
+        this.minutes = minutes;
+    }
+}

+ 231 - 0
packages/admin-ui/src/app/shared/components/datetime-picker/datetime-picker.service.ts

@@ -0,0 +1,231 @@
+import { Injectable } from '@angular/core';
+import { dayOfWeekIndex } from '@vendure/admin-ui/src/app/shared/components/datetime-picker/constants';
+import * as dayjs from 'dayjs';
+import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { CalendarView, DayCell, DayOfWeek } from './types';
+
+@Injectable()
+export class DatetimePickerService {
+    calendarView$: Observable<CalendarView>;
+    selected$: Observable<Date | null>;
+    viewing$: Observable<Date>;
+    private selectedDatetime$ = new BehaviorSubject<dayjs.Dayjs | null>(null);
+    private viewingDatetime$ = new BehaviorSubject<dayjs.Dayjs>(dayjs());
+    private weekStartDayIndex: number;
+    private min: dayjs.Dayjs | null = null;
+    private max: dayjs.Dayjs | null = null;
+    private jumping = false;
+
+    constructor() {
+        this.selected$ = this.selectedDatetime$.pipe(map(value => value && value.toDate()));
+        this.viewing$ = this.viewingDatetime$.pipe(map(value => value.toDate()));
+        this.weekStartDayIndex = dayOfWeekIndex['mon'];
+        this.calendarView$ = combineLatest(this.viewingDatetime$, this.selectedDatetime$).pipe(
+            map(([viewing, selected]) => this.generateCalendarView(viewing, selected)),
+        );
+    }
+
+    setWeekStartingDay(weekStartDay: DayOfWeek) {
+        this.weekStartDayIndex = dayOfWeekIndex[weekStartDay];
+    }
+
+    setMin(min?: string | null) {
+        if (typeof min === 'string') {
+            this.min = dayjs(min);
+        }
+    }
+
+    setMax(max?: string | null) {
+        if (typeof max === 'string') {
+            this.max = dayjs(max);
+        }
+    }
+
+    selectDatetime(date: Date | string | dayjs.Dayjs | null) {
+        let viewingValue: dayjs.Dayjs;
+        let selectedValue: dayjs.Dayjs | null = null;
+        if (date == null) {
+            viewingValue = dayjs();
+        } else {
+            viewingValue = dayjs(date);
+            selectedValue = dayjs(date);
+        }
+
+        this.selectedDatetime$.next(selectedValue);
+        this.viewingDatetime$.next(viewingValue);
+    }
+
+    selectHour(hourOfDay: number) {
+        const current = this.selectedDatetime$.value || dayjs();
+        const next = current.hour(hourOfDay);
+        this.selectedDatetime$.next(next);
+        this.viewingDatetime$.next(next);
+    }
+
+    selectMinute(minutePastHour: number) {
+        const current = this.selectedDatetime$.value || dayjs();
+        const next = current.minute(minutePastHour);
+        this.selectedDatetime$.next(next);
+        this.viewingDatetime$.next(next);
+    }
+
+    viewNextMonth() {
+        this.jumping = false;
+        const current = this.viewingDatetime$.value;
+        this.viewingDatetime$.next(current.add(1, 'month'));
+    }
+
+    viewPrevMonth() {
+        this.jumping = false;
+        const current = this.viewingDatetime$.value;
+        this.viewingDatetime$.next(current.subtract(1, 'month'));
+    }
+
+    viewToday() {
+        this.jumping = false;
+        this.viewingDatetime$.next(dayjs());
+    }
+
+    viewJumpDown() {
+        this.jumping = true;
+        const current = this.viewingDatetime$.value;
+        this.viewingDatetime$.next(current.add(1, 'week'));
+    }
+
+    viewJumpUp() {
+        this.jumping = true;
+        const current = this.viewingDatetime$.value;
+        this.viewingDatetime$.next(current.subtract(1, 'week'));
+    }
+
+    viewJumpRight() {
+        this.jumping = true;
+        const current = this.viewingDatetime$.value;
+        this.viewingDatetime$.next(current.add(1, 'day'));
+    }
+
+    viewJumpLeft() {
+        this.jumping = true;
+        const current = this.viewingDatetime$.value;
+        this.viewingDatetime$.next(current.subtract(1, 'day'));
+    }
+
+    selectToday() {
+        this.jumping = false;
+        this.selectDatetime(dayjs());
+    }
+
+    selectViewed() {
+        this.jumping = false;
+        this.selectDatetime(this.viewingDatetime$.value);
+    }
+
+    viewMonth(month: number) {
+        this.jumping = false;
+        const current = this.viewingDatetime$.value;
+        this.viewingDatetime$.next(current.month(month - 1));
+    }
+
+    viewYear(year: number) {
+        this.jumping = false;
+        const current = this.viewingDatetime$.value;
+        this.viewingDatetime$.next(current.year(year));
+    }
+
+    private generateCalendarView(viewing: dayjs.Dayjs, selected: dayjs.Dayjs | null): CalendarView {
+        if (!viewing.isValid() || (selected && !selected.isValid())) {
+            return [];
+        }
+        const start = viewing.startOf('month');
+        const end = viewing.endOf('month');
+        const today = dayjs();
+        const daysInMonth = viewing.daysInMonth();
+        const selectedDayOfMonth = selected && selected.get('date');
+
+        const startDayOfWeek = start.day();
+        const startIndex = (7 + (startDayOfWeek - this.weekStartDayIndex)) % 7;
+
+        const calendarView: CalendarView = [];
+        let week: DayCell[] = [];
+
+        // Add the days at the tail of the previous month
+        if (0 < startIndex) {
+            const prevMonth = viewing.subtract(1, 'month');
+            const daysInPrevMonth = prevMonth.daysInMonth();
+            const prevIsCurrentMonth = prevMonth.isSame(today, 'month');
+            for (let i = daysInPrevMonth - startIndex + 1; i <= daysInPrevMonth; i++) {
+                const thisDay = viewing.subtract(1, 'month').date(i);
+                week.push({
+                    dayOfMonth: i,
+                    selected: false,
+                    inCurrentMonth: false,
+                    isToday: prevIsCurrentMonth && today.get('date') === i,
+                    isViewing: false,
+                    disabled: !this.isInBounds(thisDay),
+                    select: () => {
+                        this.selectDatetime(thisDay);
+                    },
+                });
+            }
+        }
+
+        // Add this month's days
+        const isCurrentMonth = viewing.isSame(today, 'month');
+        for (let i = 1; i <= daysInMonth; i++) {
+            if ((i + startIndex - 1) % 7 === 0) {
+                calendarView.push(week);
+                week = [];
+            }
+            const thisDay = start.add(i - 1, 'day');
+            const isViewingThisMonth =
+                !!selected && selected.isSame(viewing, 'month') && selected.isSame(viewing, 'year');
+            week.push({
+                dayOfMonth: i,
+                selected: i === selectedDayOfMonth && isViewingThisMonth,
+                inCurrentMonth: true,
+                isToday: isCurrentMonth && today.get('date') === i,
+                isViewing: this.jumping && viewing.date() === i,
+                disabled: !this.isInBounds(thisDay),
+                select: () => {
+                    this.selectDatetime(thisDay);
+                },
+            });
+        }
+
+        // Add the days at the start of the next month
+        const emptyCellsEnd = 7 - ((startIndex + daysInMonth) % 7);
+        if (emptyCellsEnd !== 7) {
+            const nextMonth = viewing.add(1, 'month');
+            const nextIsCurrentMonth = nextMonth.isSame(today, 'month');
+
+            for (let i = 1; i <= emptyCellsEnd; i++) {
+                const thisDay = end.add(i, 'day');
+                week.push({
+                    dayOfMonth: i,
+                    selected: false,
+                    inCurrentMonth: false,
+                    isToday: nextIsCurrentMonth && today.get('date') === i,
+                    isViewing: false,
+                    disabled: !this.isInBounds(thisDay),
+                    select: () => {
+                        this.selectDatetime(thisDay);
+                    },
+                });
+            }
+        }
+        calendarView.push(week);
+        return calendarView;
+    }
+
+    private isInBounds(date: dayjs.Dayjs): boolean {
+        if (this.min && this.min.isAfter(date)) {
+            return false;
+        }
+        if (this.max && this.max.isBefore(date)) {
+            return false;
+        }
+        return true;
+    }
+}

+ 12 - 0
packages/admin-ui/src/app/shared/components/datetime-picker/types.ts

@@ -0,0 +1,12 @@
+export interface DayCell {
+    dayOfMonth: number;
+    inCurrentMonth: boolean;
+    selected: boolean;
+    isToday: boolean;
+    isViewing: boolean;
+    disabled: boolean;
+    select: () => void;
+}
+
+export type CalendarView = DayCell[][];
+export type DayOfWeek = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';

+ 1 - 0
packages/admin-ui/src/app/shared/shared-declarations.ts

@@ -47,6 +47,7 @@ export { SimpleDialogComponent } from './components/simple-dialog/simple-dialog.
 export { TableRowActionComponent } from './components/table-row-action/table-row-action.component';
 export { TitleInputComponent } from './components/title-input/title-input.component';
 export { EntityInfoComponent } from './components/entity-info/entity-info.component';
+export { DatetimePickerComponent } from './components/datetime-picker/datetime-picker.component';
 export { CurrencyNamePipe } from './pipes/currency-name.pipe';
 export { FileSizePipe } from './pipes/file-size.pipe';
 export { SentenceCasePipe } from './pipes/sentence-case.pipe';

+ 2 - 0
packages/admin-ui/src/app/shared/shared.module.ts

@@ -32,6 +32,7 @@ import {
     CustomFieldControlComponent,
     DataTableColumnComponent,
     DataTableComponent,
+    DatetimePickerComponent,
     DialogButtonsDirective,
     DialogComponentOutletComponent,
     DialogTitleDirective,
@@ -128,6 +129,7 @@ const DECLARATIONS = [
     AssetGalleryComponent,
     AssetPickerDialogComponent,
     EntityInfoComponent,
+    DatetimePickerComponent,
 ];
 
 @NgModule({

+ 26 - 1
packages/admin-ui/src/i18n-messages/en.json

@@ -149,10 +149,13 @@
     "remove": "Remove",
     "results-count": "{ count } {count, plural, one {result} other {results}}",
     "select": "Select...",
+    "select-today": "Select today",
     "there-are-unsaved-changes": "There are unsaved changes. Navigating away will cause these changes to be lost.",
     "update": "Update",
     "updated-at": "Updated at",
-    "username": "Username"
+    "username": "Username",
+    "view-next-month": "View next month",
+    "view-previous-month": "View previous month"
   },
   "customer": {
     "addresses": "Addresses",
@@ -185,6 +188,28 @@
     "title": "Title",
     "verified": "Verified"
   },
+  "datetime": {
+    "month-apr": "April",
+    "month-aug": "August",
+    "month-dec": "December",
+    "month-feb": "February",
+    "month-jan": "January",
+    "month-jul": "July",
+    "month-jun": "June",
+    "month-mar": "March",
+    "month-may": "May",
+    "month-nov": "November",
+    "month-oct": "October",
+    "month-sep": "September",
+    "time": "Time",
+    "weekday-fr": "Fr",
+    "weekday-mo": "Mo",
+    "weekday-sa": "Sa",
+    "weekday-su": "Su",
+    "weekday-th": "Th",
+    "weekday-tu": "Tu",
+    "weekday-we": "We"
+  },
   "error": {
     "403-forbidden": "Your session has expired. Please log in",
     "could-not-connect-to-server": "Could not connect to the Vendure server at { url }",

+ 1 - 1
packages/admin-ui/src/styles/_variables.scss

@@ -1,5 +1,5 @@
 // colors
-$color-primary-100: #e1e4ff;
+$color-primary-100: #e3f6fc;
 $color-primary-200: #afdaf3;
 $color-primary-300: #9adbf3;
 $color-primary-400: #5dcff3;

+ 5 - 0
yarn.lock

@@ -5308,6 +5308,11 @@ dateformat@^3.0.0, dateformat@^3.0.3:
   resolved "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
   integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
 
+dayjs@^1.8.16:
+  version "1.8.16"
+  resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.8.16.tgz#2a3771de537255191b947957af2fd90012e71e64"
+  integrity sha512-XPmqzWz/EJiaRHjBqSJ2s6hE/BUoCIHKgdS2QPtTQtKcS9E4/Qn0WomoH1lXanWCzri+g7zPcuNV4aTZ8PMORQ==
+
 debug@*, debug@^4.1.0, debug@^4.1.1:
   version "4.1.1"
   resolved "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"