From f4a89c5e22da1f9a236ea6d7a30db351a65d16fa Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Mon, 22 Dec 2025 22:16:15 +0100 Subject: [PATCH 1/4] feat: create spike --- .../widgets/base/scheduler/views/_index.scss | 1 + .../base/scheduler/views/year/_index.scss | 47 ++++ .../scheduler/a11y_status/a11y_status_text.ts | 1 + .../js/__internal/scheduler/header/m_utils.ts | 14 ++ .../js/__internal/scheduler/header/types.ts | 2 +- .../js/__internal/scheduler/m_scheduler.ts | 14 ++ .../js/__internal/scheduler/types.ts | 2 +- .../scheduler/utils/options/constants_view.ts | 2 + .../get_filter_options/get_filter_options.ts | 2 +- .../options/get_view_model_options.ts | 1 + .../scheduler/workspaces/m_work_space_year.ts | 218 ++++++++++++++++++ .../js/localization/messages/en.json | 1 + packages/devextreme/js/ui/scheduler.d.ts | 4 +- packages/devextreme/playground/jquery.html | 16 +- 14 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss create mode 100644 packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts diff --git a/packages/devextreme-scss/scss/widgets/base/scheduler/views/_index.scss b/packages/devextreme-scss/scss/widgets/base/scheduler/views/_index.scss index 199ed95f0d99..e1858ff06b4c 100644 --- a/packages/devextreme-scss/scss/widgets/base/scheduler/views/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/views/_index.scss @@ -60,6 +60,7 @@ $scheduler-month-date-text-padding: 6px; $scheduler-month-date-text-padding: $scheduler-month-date-text-padding, $scheduler-first-month-cell-background-color: $scheduler-first-month-cell-background-color, ); +@use "./year"; @use "./timelines" with ( $scheduler-workspace-date-table-cell-height: $scheduler-workspace-date-table-cell-height, $scheduler-accent-border: $scheduler-accent-border, diff --git a/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss b/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss new file mode 100644 index 000000000000..3b10ebf7aeec --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss @@ -0,0 +1,47 @@ +.dx-scheduler-work-space-year { + .dx-scheduler-all-day-title { + display: none; + } + + .dx-scheduler-header-panel { + display: none; + } + + .dx-scheduler-time-panel { + display: none; + } + + .dx-scheduler-year-calendars-container { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-column-gap: 12px; + grid-row-gap: 20px; + padding: 12px; + height: 100%; + width: 100%; + box-sizing: border-box; + } + + .dx-scheduler-year-calendar-label { + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + text-align: center; + } + + .dx-scheduler-year-calendar-wrapper { + display: flex; + flex-direction: column; + min-height: 0; + width: 100%; + + .dx-calendar-navigator { + display: none; + } + + .dx-calendar-cell.dx-calendar-selected-date span { + color: unset; + background-color: unset; + } + } +} \ No newline at end of file diff --git a/packages/devextreme/js/__internal/scheduler/a11y_status/a11y_status_text.ts b/packages/devextreme/js/__internal/scheduler/a11y_status/a11y_status_text.ts index a6a2e090ed18..19ab448b8f91 100644 --- a/packages/devextreme/js/__internal/scheduler/a11y_status/a11y_status_text.ts +++ b/packages/devextreme/js/__internal/scheduler/a11y_status/a11y_status_text.ts @@ -14,6 +14,7 @@ const viewTypeLocalization: Record = { agenda: 'dxScheduler-switcherAgenda', day: 'dxScheduler-switcherDay', month: 'dxScheduler-switcherMonth', + year: 'dxScheduler-switcherYear', week: 'dxScheduler-switcherWeek', workWeek: 'dxScheduler-switcherWorkWeek', timelineDay: 'dxScheduler-switcherTimelineDay', diff --git a/packages/devextreme/js/__internal/scheduler/header/m_utils.ts b/packages/devextreme/js/__internal/scheduler/header/m_utils.ts index 7c8770d56997..448e994df5f4 100644 --- a/packages/devextreme/js/__internal/scheduler/header/m_utils.ts +++ b/packages/devextreme/js/__internal/scheduler/header/m_utils.ts @@ -81,6 +81,7 @@ const getIntervalStartDate = (options: IntervalOptions) => { case 'day': case 'week': case 'month': + case 'year': return getPeriodStart(date, step, false, firstDayOfWeek); case 'workWeek': // eslint-disable-next-line no-case-declarations @@ -133,6 +134,10 @@ const getPeriodEndDate = (currentPeriodStartDate: Date, step: Step, agendaDurati case 'month': date = nextMonth(currentPeriodStartDate); break; + case 'year': + date = new Date(currentPeriodStartDate); + date.setFullYear(date.getFullYear() + 1); + break; case 'workWeek': date = getDateAfterWorkWeek(currentPeriodStartDate); break; @@ -176,6 +181,10 @@ export const getNextIntervalDate = (options, direction: Direction): Date => { break; case 'month': return getNextMonthDate(date, intervalCount, direction); + case 'year': + { const nextYearDate = new Date(date); + nextYearDate.setFullYear(nextYearDate.getFullYear() + intervalCount * direction); + return nextYearDate; } } return addDateInterval(date, { days: dayDuration }, direction); @@ -299,6 +308,10 @@ const getCaptionText = (startDate: Date, endDate: Date, isShort: boolean, step): return formatMonthViewCaption(startDate, endDate); } + if (step === 'year') { + return String(formatDate(startDate, 'year') ?? ''); + } + return formatCaptionByMonths(startDate, endDate, isShort); }; @@ -319,6 +332,7 @@ const STEP_MAP: Record = { week: 'week', workWeek: 'workWeek', month: 'month', + year: 'year', timelineDay: 'day', timelineWeek: 'week', timelineWorkWeek: 'workWeek', diff --git a/packages/devextreme/js/__internal/scheduler/header/types.ts b/packages/devextreme/js/__internal/scheduler/header/types.ts index 98c2ca07eab7..a7a3e7c36e24 100644 --- a/packages/devextreme/js/__internal/scheduler/header/types.ts +++ b/packages/devextreme/js/__internal/scheduler/header/types.ts @@ -18,7 +18,7 @@ export interface HeaderOptions { customizeDateNavigatorText: SafeSchedulerOptions['customizeDateNavigatorText']; } -export type Step = 'day' | 'week' | 'workWeek' | 'month' | 'agenda'; +export type Step = 'day' | 'week' | 'workWeek' | 'month' | 'year' | 'agenda'; export interface IntervalOptions { date: Date; diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 9b896fb7fa56..77a6b50d3c66 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -89,6 +89,7 @@ import SchedulerWorkSpaceDay from './workspaces/m_work_space_day'; import SchedulerWorkSpaceMonth from './workspaces/m_work_space_month'; import SchedulerWorkSpaceWeek from './workspaces/m_work_space_week'; import SchedulerWorkSpaceWorkWeek from './workspaces/m_work_space_work_week'; +import SchedulerWorkSpaceYear from './workspaces/m_work_space_year'; const toMs = dateUtils.dateToMilliseconds; @@ -119,6 +120,10 @@ const VIEWS_CONFIG = { workSpace: SchedulerWorkSpaceMonth, renderingStrategy: 'horizontalMonth', }, + year: { + workSpace: SchedulerWorkSpaceYear, + renderingStrategy: 'horizontalMonth', + }, timelineDay: { workSpace: SchedulerTimelineDay, renderingStrategy: 'horizontal', @@ -1308,6 +1313,15 @@ class Scheduler extends SchedulerOptionsBaseWidget { const currentViewType = this.currentView.type; const workSpaceComponent = VIEWS_CONFIG[currentViewType].workSpace; const workSpaceConfig = this._workSpaceConfig(this.currentView); + + // Add callback for year view date click + if (currentViewType === 'year') { + workSpaceConfig.onDateClick = (date: Date) => { + this.option('currentView', 'day'); + this.option('currentDate', date); + }; + } + // @ts-expect-error this._workSpace = this._createComponent($workSpace, workSpaceComponent, workSpaceConfig); diff --git a/packages/devextreme/js/__internal/scheduler/types.ts b/packages/devextreme/js/__internal/scheduler/types.ts index 5326f622e1d6..4a2346f76e1f 100644 --- a/packages/devextreme/js/__internal/scheduler/types.ts +++ b/packages/devextreme/js/__internal/scheduler/types.ts @@ -6,7 +6,7 @@ import type { AppointmentViewModelPlain } from './view_model/types'; export type Direction = 'vertical' | 'horizontal'; export type GroupOrientation = 'vertical' | 'horizontal'; -export type ViewType = 'agenda' | 'day' | 'month' | 'timelineDay' | 'timelineMonth' | 'timelineWeek' | 'timelineWorkWeek' | 'week' | 'workWeek'; +export type ViewType = 'agenda' | 'day' | 'month' | 'year' | 'timelineDay' | 'timelineMonth' | 'timelineWeek' | 'timelineWorkWeek' | 'week' | 'workWeek'; export type AllDayPanelModeType = 'all' | 'allDay' | 'hidden'; export type RenderStrategyName = 'horizontal' | 'horizontalMonth' | 'horizontalMonthLine' | 'vertical' | 'week' | 'agenda'; export type FilterItemType = Record | string | number; diff --git a/packages/devextreme/js/__internal/scheduler/utils/options/constants_view.ts b/packages/devextreme/js/__internal/scheduler/utils/options/constants_view.ts index d01f374237bd..1d455f1afe0d 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/options/constants_view.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/options/constants_view.ts @@ -5,6 +5,7 @@ export const VIEWS: Record = { WEEK: 'week', WORK_WEEK: 'workWeek', MONTH: 'month', + YEAR: 'year', TIMELINE_DAY: 'timelineDay', TIMELINE_WEEK: 'timelineWeek', TIMELINE_WORK_WEEK: 'timelineWorkWeek', @@ -32,6 +33,7 @@ export const DEFAULT_VIEW_OPTIONS: Record, View> & { week: getView('week', 'horizontal'), workWeek: getView('workWeek', 'horizontal', WEEKENDS), month: getView('month', 'horizontal'), + year: getView('year', 'horizontal'), timelineDay: getView('timelineDay', 'vertical'), timelineWeek: getView('timelineWeek', 'vertical'), timelineWorkWeek: getView('timelineWorkWeek', 'vertical', WEEKENDS), diff --git a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/get_filter_options/get_filter_options.ts b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/get_filter_options/get_filter_options.ts index 3f4f4ee9c500..65f38aaf6e18 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/get_filter_options/get_filter_options.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/get_filter_options/get_filter_options.ts @@ -6,7 +6,7 @@ import type { FilterOptions } from '../../../types'; import { getVisibleDateTimeIntervals } from './get_visible_date_time_intervals'; const VIEWS_WITH_ALL_DAY_PANEL: ViewType[] = ['day', 'week', 'workWeek']; -const DATE_TIME_VIEWS: ViewType[] = ['day', 'week', 'workWeek', 'timelineDay', 'timelineWeek', 'timelineWorkWeek']; +const DATE_TIME_VIEWS: ViewType[] = ['day', 'week', 'workWeek', 'year', 'timelineDay', 'timelineWeek', 'timelineWorkWeek']; export const getFilterOptions = (schedulerStore: Scheduler): FilterOptions => { const compareOptions = getCompareOptions(schedulerStore); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.ts index c5656557dcd0..1c58ad305b4c 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_view_model_options.ts @@ -14,6 +14,7 @@ const configByView: Record, { week: { isTimelineView: false, isMonthView: false, viewOrientation: 'vertical' }, workWeek: { isTimelineView: false, isMonthView: false, viewOrientation: 'vertical' }, month: { isTimelineView: false, isMonthView: true, viewOrientation: 'horizontal' }, + year: { isTimelineView: false, isMonthView: true, viewOrientation: 'horizontal' }, timelineDay: { isTimelineView: true, isMonthView: false, viewOrientation: 'horizontal' }, timelineWeek: { isTimelineView: true, isMonthView: false, viewOrientation: 'horizontal' }, timelineWorkWeek: { isTimelineView: true, isMonthView: false, viewOrientation: 'horizontal' }, diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts new file mode 100644 index 000000000000..0208575da99e --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts @@ -0,0 +1,218 @@ +import '@js/ui/calendar'; + +import dateLocalization from '@js/common/core/localization/date'; +import registerComponent from '@js/core/component_registrator'; +import $ from '@js/core/renderer'; +import { noop } from '@js/core/utils/common'; +import dateUtils from '@js/core/utils/date'; + +import { VIEWS } from '../utils/options/constants_view'; +import SchedulerWorkSpace from './m_work_space'; + +const YEAR_CLASS = 'dx-scheduler-work-space-year'; +const YEAR_CALENDARS_CONTAINER_CLASS = 'dx-scheduler-year-calendars-container'; +const YEAR_CALENDAR_ITEM_CLASS = 'dx-scheduler-year-calendar-item'; + +class SchedulerWorkSpaceYear extends SchedulerWorkSpace { + _calendars: any[] = []; + + _$workSpace: any; + + get type() { return VIEWS.YEAR; } + + _init() { + super._init(); + if (!this._calendars) { + this._calendars = []; + } + } + + _getElementClass() { + return YEAR_CLASS; + } + + _getViewStartByOptions() { + const currentDate = this.option('currentDate') as Date; + const yearStart = new Date(currentDate.getFullYear(), 0, 1); + return dateUtils.trimTime(yearStart); + } + + getStartViewDate() { + return this._getViewStartByOptions(); + } + + getEndViewDate() { + const yearStart = this.getStartViewDate(); + return new Date(yearStart.getFullYear() + 1, 0, 0, 23, 59, 59); + } + + getDateRange() { + return [this.getStartViewDate(), this.getEndViewDate()]; + } + + _createWorkSpaceElements() { + if (this._$dateTable && this._$dateTable.length) { + this._disposeCalendars(); + this._$dateTable.remove(); + } + + if (!this._$workSpace) { + this._$workSpace = $('
').addClass('dx-scheduler-work-space'); + this.$element().append(this._$workSpace); + } + + const $container = $('
').addClass(YEAR_CALENDARS_CONTAINER_CLASS); + this._calendars = []; + + const currentYear = this.getStartViewDate().getFullYear(); + const firstDayOfWeek = this.option('firstDayOfWeek') as number; + + for (let month = 0; month < 12; month++) { + const $calendarItem = $('
').addClass(YEAR_CALENDAR_ITEM_CLASS); + const monthDate = new Date(currentYear, month, 1); + + const monthNames = dateLocalization.getMonthNames(); + const monthName = monthNames[month]; + const $monthLabel = $('
') + .addClass('dx-scheduler-year-calendar-label') + .text(monthName); + + $calendarItem.append($monthLabel); + + const $calendarContainer = $('
').addClass('dx-scheduler-year-calendar-wrapper'); + const calendarOptions: any = { + value: monthDate, + date: monthDate, + firstDayOfWeek, + zoomLevel: 'month', + disabled: false, + focusStateEnabled: this.option('focusStateEnabled'), + tabIndex: this.option('tabIndex'), + onCellClick: (e: any) => { + const clickedDate = e.value; + if (clickedDate instanceof Date) { + this._onCalendarDateClick(clickedDate); + } + }, + }; + + // @ts-expect-error + const calendar = this._createComponent($calendarContainer, 'dxCalendar', calendarOptions); + this._calendars.push(calendar); + $calendarItem.append($calendarContainer); + $container.append($calendarItem); + } + + this._$dateTable = $container; + this._$dateTable.appendTo(this._$workSpace); + } + + _onCalendarDateClick(date: Date) { + const onDateClick = this.option('onDateClick') as any; + if (onDateClick) { + onDateClick(date); + } + } + + getWorkArea() { + return this._$workSpace || this.$element(); + } + + _renderView() { + this._createWorkSpaceElements(); + } + + _renderTimePanel() { return noop(); } + + _renderAllDayPanel() { return noop(); } + + _renderDateTable() { return noop(); } + + _createAllDayPanelElements() {} + + _insertAllDayRowsIntoDateTable() { return false; } + + supportAllDayRow() { + return false; + } + + keepOriginalHours() { + return true; + } + + getTimePanelWidth() { + return 0; + } + + getWorkSpaceLeftOffset() { + return 0; + } + + isIndicationAvailable() { + return false; + } + + getIntervalDuration() { + return dateUtils.dateToMilliseconds('day'); + } + + _getHeaderDate() { + return this._getViewStartByOptions(); + } + + _getCellCoordinatesByIndex() { + return { rowIndex: 0, columnIndex: 0 }; + } + + _getCellCount() { + return 0; + } + + _getCells() { + return $(); + } + + getCellWidth() { + return 0; + } + + getCellHeight() { + return 0; + } + + _needCreateCrossScrolling() { + return false; + } + + _optionChanged(args) { + const { name } = args; + + if (name === 'currentDate' || name === 'firstDayOfWeek') { + this._disposeCalendars(); + this._renderView(); + } else { + super._optionChanged(args); + } + } + + _disposeCalendars() { + if (this._calendars && Array.isArray(this._calendars)) { + this._calendars.forEach((calendar) => { + calendar?.dispose(); + }); + } + this._calendars = []; + if (this._$dateTable) { + this._$dateTable.empty(); + } + } + + _dispose() { + this._disposeCalendars(); + super._dispose(); + } +} + +registerComponent('dxSchedulerWorkSpaceYear', SchedulerWorkSpaceYear as any); + +export default SchedulerWorkSpaceYear; diff --git a/packages/devextreme/js/localization/messages/en.json b/packages/devextreme/js/localization/messages/en.json index 7ba245909f55..d40b5c63febd 100644 --- a/packages/devextreme/js/localization/messages/en.json +++ b/packages/devextreme/js/localization/messages/en.json @@ -322,6 +322,7 @@ "dxScheduler-switcherWeek": "Week", "dxScheduler-switcherWorkWeek": "Work Week", "dxScheduler-switcherMonth": "Month", + "dxScheduler-switcherYear": "Year", "dxScheduler-switcherAgenda": "Agenda", diff --git a/packages/devextreme/js/ui/scheduler.d.ts b/packages/devextreme/js/ui/scheduler.d.ts index 4920cd3953ed..c84ad26065b1 100644 --- a/packages/devextreme/js/ui/scheduler.d.ts +++ b/packages/devextreme/js/ui/scheduler.d.ts @@ -94,7 +94,7 @@ export type AppointmentFormProperties = FormProperties & { iconsShowMode?: AppointmentFormIconsShowMode; }; /** @public */ -export type ViewType = 'agenda' | 'day' | 'month' | 'timelineDay' | 'timelineMonth' | 'timelineWeek' | 'timelineWorkWeek' | 'week' | 'workWeek'; +export type ViewType = 'agenda' | 'day' | 'month' | 'year' | 'timelineDay' | 'timelineMonth' | 'timelineWeek' | 'timelineWorkWeek' | 'week' | 'workWeek'; /** @public */ export type SchedulerPredefinedToolbarItem = 'today' | 'dateNavigator' | 'viewSwitcher'; /** @public */ @@ -1024,7 +1024,7 @@ export interface dxSchedulerOptions extends WidgetOptions { * @default ['day', 'week'] * @public */ - views?: Array<'day' | 'week' | 'workWeek' | 'month' | 'timelineDay' | 'timelineWeek' | 'timelineWorkWeek' | 'timelineMonth' | 'agenda' | { + views?: Array<'day' | 'week' | 'workWeek' | 'month' | 'year' | 'timelineDay' | 'timelineWeek' | 'timelineWorkWeek' | 'timelineMonth' | 'agenda' | { /** * @docid * @default 7 diff --git a/packages/devextreme/playground/jquery.html b/packages/devextreme/playground/jquery.html index abaed02d03f2..c4bce5ca267b 100644 --- a/packages/devextreme/playground/jquery.html +++ b/packages/devextreme/playground/jquery.html @@ -40,6 +40,8 @@ + + @@ -49,15 +51,21 @@

Te
-
+

+ From 8eb0067837b029f71fda2f1ddc63fdc5485b36bc Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Tue, 23 Dec 2025 11:03:29 +0100 Subject: [PATCH 2/4] feat: add custom calendar --- .../base/scheduler/views/year/_index.scss | 61 +++-- .../scheduler/workspaces/m_work_space_year.ts | 22 +- .../scheduler/workspaces/m_year_calendar.ts | 209 ++++++++++++++++++ 3 files changed, 238 insertions(+), 54 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts diff --git a/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss b/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss index 3b10ebf7aeec..a43375b70967 100644 --- a/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss @@ -1,47 +1,38 @@ .dx-scheduler-work-space-year { - .dx-scheduler-all-day-title { - display: none; - } - - .dx-scheduler-header-panel { - display: none; - } - - .dx-scheduler-time-panel { - display: none; - } - .dx-scheduler-year-calendars-container { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; grid-column-gap: 12px; grid-row-gap: 20px; padding: 12px; - height: 100%; - width: 100%; - box-sizing: border-box; - } - - .dx-scheduler-year-calendar-label { - font-size: 14px; - font-weight: 500; - margin-bottom: 8px; - text-align: center; - } - .dx-scheduler-year-calendar-wrapper { - display: flex; - flex-direction: column; - min-height: 0; - width: 100%; - - .dx-calendar-navigator { - display: none; - } + .dx-scheduler-year-calendar-item { + .dx-scheduler-year-calendar-label { + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + text-align: center; + } + + table { + width: 100%; + table-layout: fixed; + border-spacing: 0; + line-height: normal; + } + + td, + th { + padding: 0; + } + + .dx-calendar-cell { + cursor: pointer; - .dx-calendar-cell.dx-calendar-selected-date span { - color: unset; - background-color: unset; + &:hover { + background-color: $scheduler-workspace-hovered-cell-color; + } + } } } } \ No newline at end of file diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts index 0208575da99e..4cb02a3bda04 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts @@ -1,6 +1,3 @@ -import '@js/ui/calendar'; - -import dateLocalization from '@js/common/core/localization/date'; import registerComponent from '@js/core/component_registrator'; import $ from '@js/core/renderer'; import { noop } from '@js/core/utils/common'; @@ -8,6 +5,7 @@ import dateUtils from '@js/core/utils/date'; import { VIEWS } from '../utils/options/constants_view'; import SchedulerWorkSpace from './m_work_space'; +import YearCalendar from './m_year_calendar'; const YEAR_CLASS = 'dx-scheduler-work-space-year'; const YEAR_CALENDARS_CONTAINER_CLASS = 'dx-scheduler-year-calendars-container'; @@ -71,23 +69,10 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { const $calendarItem = $('
').addClass(YEAR_CALENDAR_ITEM_CLASS); const monthDate = new Date(currentYear, month, 1); - const monthNames = dateLocalization.getMonthNames(); - const monthName = monthNames[month]; - const $monthLabel = $('
') - .addClass('dx-scheduler-year-calendar-label') - .text(monthName); - - $calendarItem.append($monthLabel); - - const $calendarContainer = $('
').addClass('dx-scheduler-year-calendar-wrapper'); const calendarOptions: any = { - value: monthDate, date: monthDate, firstDayOfWeek, - zoomLevel: 'month', - disabled: false, - focusStateEnabled: this.option('focusStateEnabled'), - tabIndex: this.option('tabIndex'), + showMonthLabel: true, onCellClick: (e: any) => { const clickedDate = e.value; if (clickedDate instanceof Date) { @@ -97,9 +82,8 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { }; // @ts-expect-error - const calendar = this._createComponent($calendarContainer, 'dxCalendar', calendarOptions); + const calendar = this._createComponent($calendarItem, YearCalendar, calendarOptions); this._calendars.push(calendar); - $calendarItem.append($calendarContainer); $container.append($calendarItem); } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts new file mode 100644 index 000000000000..d244cb125424 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts @@ -0,0 +1,209 @@ +import { name as clickEventName } from '@js/common/core/events/click'; +import eventsEngine from '@js/common/core/events/core/events_engine'; +import { addNamespace } from '@js/common/core/events/utils/index'; +import dateLocalization from '@js/common/core/localization/date'; +import registerComponent from '@js/core/component_registrator'; +import $ from '@js/core/renderer'; +import dateUtils from '@js/core/utils/date'; +import type { WidgetProperties } from '@ts/core/widget/widget'; +import Widget from '@ts/core/widget/widget'; + +const CALENDAR_CELL_CLASS = 'dx-calendar-cell'; +const CALENDAR_OTHER_MONTH_CLASS = 'dx-calendar-other-month'; +const CALENDAR_OTHER_VIEW_CLASS = 'dx-calendar-other-view'; +const YEAR_CALENDAR_LABEL_CLASS = 'dx-scheduler-year-calendar-label'; + +const CALENDAR_DXCLICK_EVENT_NAME = addNamespace(clickEventName, 'dxYearCalendar'); + +interface YearCalendarProperties extends WidgetProperties { + date: Date; + firstDayOfWeek?: number; + onCellClick?: (e: { value: Date }) => void; + showMonthLabel?: boolean; +} + +class YearCalendar extends Widget { + readonly _viewName = 'yearCalendar'; + + _getDefaultOptions(): YearCalendarProperties { + return { + ...super._getDefaultOptions(), + date: new Date(), + firstDayOfWeek: dateLocalization.firstDayOfWeekIndex(), + onCellClick: undefined, + showMonthLabel: true, + }; + } + + _init(): void { + super._init(); + this._render(); + } + + _render(): void { + this.$element().empty(); + this._renderMonthLabel(); + this._renderTable(); + this._renderEvents(); + } + + _renderMonthLabel(): void { + const showMonthLabel = this.option('showMonthLabel') as unknown as boolean | undefined; + if (showMonthLabel !== false) { + const date = this.option('date') as unknown as Date; + const monthNames = dateLocalization.getMonthNames(); + const monthName = monthNames[date.getMonth()]; + const $label = $('
') + .addClass(YEAR_CALENDAR_LABEL_CLASS) + .text(monthName); + this.$element().append($label); + } + } + + _renderTable(): void { + const date = this.option('date') as unknown as Date; + const firstDayOfWeek = (this.option('firstDayOfWeek') as unknown as number | undefined) ?? dateLocalization.firstDayOfWeekIndex(); + + const $table = $('') + .attr('role', 'grid') + .attr('aria-label', `Calendar. Month ${dateLocalization.format(date, 'monthandyear')}`); + + // Render header with day names + const $thead = $(''); + const $headerRow = $(''); + const dayNames = dateLocalization.getDayNames('abbreviated'); + + // Rotate day names based on firstDayOfWeek + const rotatedDayNames = [ + ...dayNames.slice(firstDayOfWeek), + ...dayNames.slice(0, firstDayOfWeek), + ]; + + rotatedDayNames.forEach((dayName, index) => { + const dayIndex = (firstDayOfWeek + index) % 7; + const $th = $(''); + const days = YearCalendar._getMonthDays(date, firstDayOfWeek); + + // Group days into weeks (rows) + const weeks: Date[][] = []; + for (let i = 0; i < days.length; i += 7) { + weeks.push(days.slice(i, i + 7)); + } + + weeks.forEach((weekDays) => { + const $row = $('').attr('role', 'row'); + weekDays.forEach((dayDate) => { + const isOtherMonth = dayDate.getMonth() !== date.getMonth(); + const dayNumber = dayDate.getDate(); + + const $cell = $('
') + .attr('scope', 'col') + .attr('abbr', dayNames[dayIndex]) + .text(dayName); + $headerRow.append($th); + }); + + $thead.append($headerRow); + $table.append($thead); + + // Render body with days + const $tbody = $('
') + .addClass(CALENDAR_CELL_CLASS) + .attr('data-value', YearCalendar._formatDateValue(dayDate)) + .attr('aria-selected', 'false') + .attr('aria-label', YearCalendar._getAriaLabel(dayDate)); + + if (isOtherMonth) { + $cell.addClass(CALENDAR_OTHER_MONTH_CLASS).addClass(CALENDAR_OTHER_VIEW_CLASS); + } + + const $span = $('').text(dayNumber.toString()); + $cell.append($span); + $row.append($cell); + }); + $tbody.append($row); + }); + + $table.append($tbody); + this.$element().append($table); + } + + static _getMonthDays(date: Date, firstDayOfWeek: number): Date[] { + const firstDayOfMonth = dateUtils.getFirstMonthDate(date) as Date; + + // Get first day of the calendar view (may be from previous month) + const firstDay = YearCalendar._getFirstCellDate(firstDayOfMonth, firstDayOfWeek); + + const days: Date[] = []; + + // Generate 6 weeks of days (42 days total) + for (let i = 0; i < 42; i += 1) { + const dayDate = new Date(firstDay); + dayDate.setDate(firstDay.getDate() + i); + days.push(dayDate); + } + + return days; + } + + static _getFirstCellDate(firstDayOfMonth: Date, firstDayOfWeek: number): Date { + const firstDay = new Date(firstDayOfMonth); + let firstMonthDayOffset = firstDayOfWeek - firstDay.getDay(); + + if (firstMonthDayOffset >= 0) { + firstMonthDayOffset -= 7; + } + + firstDay.setDate(firstDay.getDate() + firstMonthDayOffset); + return firstDay; + } + + static _formatDateValue(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}/${month}/${day}`; + } + + static _getAriaLabel(date: Date): string { + const dayNames = dateLocalization.getDayNames(); + const monthNames = dateLocalization.getMonthNames(); + const dayName = dayNames[date.getDay()]; + const monthName = monthNames[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + return `${dayName}, ${monthName} ${day}, ${year}`; + } + + _renderEvents(): void { + eventsEngine.off(this.$element(), CALENDAR_DXCLICK_EVENT_NAME); + eventsEngine.on(this.$element(), CALENDAR_DXCLICK_EVENT_NAME, `.${CALENDAR_CELL_CLASS}`, (e) => { + const $cell = $(e.currentTarget); + const dateValue = $cell.attr('data-value'); + + if (dateValue) { + const [year, month, day] = dateValue.split('/').map(Number); + const clickedDate = new Date(year, month - 1, day); + + const onCellClick = this.option('onCellClick') as unknown as ((e: { value: Date }) => void) | undefined; + if (onCellClick) { + onCellClick({ value: clickedDate }); + } + } + }); + } + + _optionChanged(args: { name: string; value?: unknown; previousValue?: unknown }): void { + const { name } = args; + + if (name === 'date' || name === 'firstDayOfWeek' || name === 'showMonthLabel') { + this._render(); + } else { + super._optionChanged(args); + } + } +} + +registerComponent('dxYearCalendar', YearCalendar); + +export default YearCalendar; From de297ce38d49325455f46fcab2e89a2a4e7f9074 Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Tue, 23 Dec 2025 18:07:10 +0100 Subject: [PATCH 3/4] feat: onCellClick callback and highlight appointments for days --- .../base/scheduler/views/year/_index.scss | 17 +- .../js/__internal/scheduler/m_scheduler.ts | 4 + .../view_model/appointments_layout_manager.ts | 3 + .../scheduler/workspaces/m_work_space_year.ts | 278 +++++++++++++----- .../scheduler/workspaces/m_year_calendar.ts | 106 +++++-- 5 files changed, 319 insertions(+), 89 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss b/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss index a43375b70967..fed9cc2b54d1 100644 --- a/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/views/year/_index.scss @@ -29,8 +29,23 @@ .dx-calendar-cell { cursor: pointer; + &.dx-calendar-other-month { + opacity: 0.5; + } + &:hover { - background-color: $scheduler-workspace-hovered-cell-color; + background-color: #f5f5f5; + } + + .dx-scheduler-year-calendar-has-appointment { + background-color: #337ab7; + color: #fff; + border-radius: 50%; + display: inline-block; + width: 1.5em; + height: 1.5em; + line-height: 1.5em; + text-align: center; } } } diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 77a6b50d3c66..00f1fbb99331 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -893,6 +893,10 @@ class Scheduler extends SchedulerOptionsBaseWidget { if (this._isAgenda()) { this._workSpace.renderAgendaLayout(viewModel); } + + if (this.currentView.type === 'year') { + workspace.repaint(); + } } _initExpressions(fields: IFieldExpr) { diff --git a/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts b/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts index 60b063072665..325691db8fe1 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/appointments_layout_manager.ts @@ -39,6 +39,9 @@ class AppointmentLayoutManager { public generateViewModel(): AppointmentViewModelPlain[] { const viewType = this.schedulerStore.currentView.type; + if (viewType === 'year') { + return []; + } if (viewType === 'agenda') { const viewModel = generateAgendaViewModel(this.schedulerStore, this.filteredItems); return viewModel.map((item) => ({ diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts index 4cb02a3bda04..7ced06e5c882 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts @@ -1,9 +1,14 @@ +/* eslint-disable class-methods-use-this */ import registerComponent from '@js/core/component_registrator'; +import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { noop } from '@js/core/utils/common'; import dateUtils from '@js/core/utils/date'; +import type { ViewType } from '@js/ui/scheduler'; +import { formatWeekday } from '@ts/scheduler/r1/utils/index'; import { VIEWS } from '../utils/options/constants_view'; +import type { ListEntity } from '../view_model/types'; import SchedulerWorkSpace from './m_work_space'; import YearCalendar from './m_year_calendar'; @@ -12,43 +17,80 @@ const YEAR_CALENDARS_CONTAINER_CLASS = 'dx-scheduler-year-calendars-container'; const YEAR_CALENDAR_ITEM_CLASS = 'dx-scheduler-year-calendar-item'; class SchedulerWorkSpaceYear extends SchedulerWorkSpace { - _calendars: any[] = []; + _calendars: YearCalendar[] = []; - _$workSpace: any; + _$workSpace!: dxElementWrapper; - get type() { return VIEWS.YEAR; } + get type(): ViewType { return VIEWS.YEAR; } - _init() { - super._init(); - if (!this._calendars) { - this._calendars = []; - } + _getElementClass(): string { return YEAR_CLASS; } + + _getFormat(): (date: Date) => string { return formatWeekday; } + + _renderTimePanel(): void { return noop(); } + + _renderAllDayPanel(): void { return noop(); } + + _renderDateTable(): void { return noop(); } + + _createAllDayPanelElements(): void { return noop(); } + + _insertAllDayRowsIntoDateTable(): boolean { return false; } + + supportAllDayRow(): boolean { return false; } + + keepOriginalHours(): boolean { return true; } + + getTimePanelWidth(): number { return 0; } + + getWorkSpaceLeftOffset(): number { return 0; } + + isIndicationAvailable(): boolean { return false; } + + getIntervalDuration(): number { + return dateUtils.dateToMilliseconds('day'); } - _getElementClass() { - return YEAR_CLASS; + _getCellCoordinatesByIndex(): { rowIndex: number; columnIndex: number } { + return { rowIndex: 0, columnIndex: 0 }; } - _getViewStartByOptions() { + _getCellCount(): number { return 0; } + + _getCells(): dxElementWrapper { return $(); } + + getCellWidth(): number { return 0; } + + getCellHeight(): number { return 0; } + + _needCreateCrossScrolling(): boolean { return false; } + + _focusOutHandler(): void { return noop(); } + + _getScrollCoordinates(): { left: number; top: number } { + return { left: 0, top: 0 }; + } + + _getViewStartByOptions(): Date { const currentDate = this.option('currentDate') as Date; const yearStart = new Date(currentDate.getFullYear(), 0, 1); - return dateUtils.trimTime(yearStart); + return dateUtils.trimTime(yearStart) as Date; } - getStartViewDate() { + getStartViewDate(): Date { return this._getViewStartByOptions(); } - getEndViewDate() { + getEndViewDate(): Date { const yearStart = this.getStartViewDate(); return new Date(yearStart.getFullYear() + 1, 0, 0, 23, 59, 59); } - getDateRange() { + getDateRange(): [Date, Date] { return [this.getStartViewDate(), this.getEndViewDate()]; } - _createWorkSpaceElements() { + _createWorkSpaceElements(): void { if (this._$dateTable && this._$dateTable.length) { this._disposeCalendars(); this._$dateTable.remove(); @@ -59,11 +101,15 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { this.$element().append(this._$workSpace); } + this._createCellClickAction(); + const $container = $('
').addClass(YEAR_CALENDARS_CONTAINER_CLASS); this._calendars = []; const currentYear = this.getStartViewDate().getFullYear(); const firstDayOfWeek = this.option('firstDayOfWeek') as number; + const hasAppointment = this._createHasAppointmentChecker(); + const getAppointmentColor = this._createGetAppointmentColor(); for (let month = 0; month < 12; month++) { const $calendarItem = $('
').addClass(YEAR_CALENDAR_ITEM_CLASS); @@ -73,11 +119,10 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { date: monthDate, firstDayOfWeek, showMonthLabel: true, + hasAppointment, + getAppointmentColor, onCellClick: (e: any) => { - const clickedDate = e.value; - if (clickedDate instanceof Date) { - this._onCalendarDateClick(clickedDate); - } + this._handleYearCalendarCellClick(e); }, }; @@ -91,84 +136,177 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { this._$dateTable.appendTo(this._$workSpace); } - _onCalendarDateClick(date: Date) { - const onDateClick = this.option('onDateClick') as any; - if (onDateClick) { - onDateClick(date); + _createHasAppointmentChecker(): (date: Date) => boolean { + const notifyScheduler = this.option('notifyScheduler') as any; + const scheduler = notifyScheduler?.scheduler; + + if (!scheduler) { + return () => false; } - } - getWorkArea() { - return this._$workSpace || this.$element(); - } + const dataAccessors = scheduler._dataAccessors; - _renderView() { - this._createWorkSpaceElements(); - } + if (!dataAccessors) { + return () => false; + } - _renderTimePanel() { return noop(); } + return (date: Date): boolean => { + const dataSource = scheduler.getDataSource()?.items() || []; - _renderAllDayPanel() { return noop(); } + const dayStart = new Date(date); + dayStart.setHours(0, 0, 0, 0); + const dayStartTime = dayStart.getTime(); + const dayEnd = new Date(date); + dayEnd.setHours(23, 59, 59, 999); + const dayEndTime = dayEnd.getTime(); - _renderDateTable() { return noop(); } + return dataSource.some((appointment: any) => { + const startDate = dataAccessors.get('startDate', appointment); + const endDate = dataAccessors.get('endDate', appointment); - _createAllDayPanelElements() {} + if (!startDate || !endDate) { + return false; + } - _insertAllDayRowsIntoDateTable() { return false; } + const start = new Date(startDate); + const startTime = start.getTime(); + const end = new Date(endDate); + const endTime = end.getTime(); - supportAllDayRow() { - return false; + return startTime <= dayEndTime && endTime >= dayStartTime; + }); + }; } - keepOriginalHours() { - return true; - } + _createGetAppointmentColor(): (date: Date) => Promise { + const notifyScheduler = this.option('notifyScheduler') as any; + const scheduler = notifyScheduler?.scheduler; - getTimePanelWidth() { - return 0; - } + if (!scheduler) { + return () => Promise.resolve(undefined); + } - getWorkSpaceLeftOffset() { - return 0; - } + const dataAccessors = scheduler._dataAccessors; + const getResourceManager = this.option('getResourceManager') as any; - isIndicationAvailable() { - return false; - } + if (!dataAccessors || !getResourceManager) { + return () => Promise.resolve(undefined); + } - getIntervalDuration() { - return dateUtils.dateToMilliseconds('day'); + return async (date: Date): Promise => { + try { + const dataSource = scheduler.getDataSource()?.items() || []; + + const dayStart = new Date(date); + dayStart.setHours(0, 0, 0, 0); + const dayStartTime = dayStart.getTime(); + const dayEnd = new Date(date); + dayEnd.setHours(23, 59, 59, 999); + const dayEndTime = dayEnd.getTime(); + + const appointment = dataSource.find((appt: any) => { + const startDate = dataAccessors.get('startDate', appt); + const endDate = dataAccessors.get('endDate', appt); + + if (!startDate || !endDate) { + return false; + } + + const start = new Date(startDate); + const startTime = start.getTime(); + const end = new Date(endDate); + const endTime = end.getTime(); + + return startTime <= dayEndTime && endTime >= dayStartTime; + }); + + if (!appointment) { + return undefined; + } + + const resourceManager = getResourceManager(); + + const color = await resourceManager.getAppointmentColor({ + itemData: appointment, + groupIndex: 0, + }); + + return color; + } catch (e) { + return undefined; + } + }; } - _getHeaderDate() { - return this._getViewStartByOptions(); + _createCellClickAction(): void { + this._cellClickAction = this._createActionByOption('onCellClick', { + afterExecute: (actionArgs: any) => { + const args = actionArgs.args[0]; + if (!args.cancel) { + const notifyScheduler = this.option('notifyScheduler') as any; + const scheduler = notifyScheduler?.scheduler; + if (scheduler && scheduler.showAddAppointmentPopup) { + scheduler.showAddAppointmentPopup(args.cellData, args.cellData.groups || {}); + } + } + }, + }); } - _getCellCoordinatesByIndex() { - return { rowIndex: 0, columnIndex: 0 }; + _handleYearCalendarCellClick(e: any): void { + const notifyScheduler = this.option('notifyScheduler') as any; + const scheduler = notifyScheduler?.scheduler; + + const args = { + cancel: false, + cellData: e.cellData, + cellElement: e.cellElement, + component: scheduler, + element: this.element(), + event: e.event, + }; + + this._cellClickAction(args); } - _getCellCount() { - return 0; + getCellData($cell: dxElementWrapper): ListEntity { + const $cellElement = $($cell); + const dateValue = $cellElement.attr('data-value') as string; + + const [year, month, day] = dateValue.split('/').map(Number); + const clickedDate = new Date(year, month - 1, day); + const startDate = new Date(clickedDate); + startDate.setHours(0, 0, 0, 0); + const endDate = new Date(clickedDate); + endDate.setHours(23, 59, 59, 999); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this._normalizeCellData({ + startDate, + endDate, + groups: {}, + groupIndex: 0, + allDay: true, + }); } - _getCells() { - return $(); + getWorkArea(): dxElementWrapper { + return this._$workSpace || this.$element(); } - getCellWidth() { - return 0; + _renderView(): void { + this._createWorkSpaceElements(); } - getCellHeight() { - return 0; + _getHeaderDate(): Date { + return this._getViewStartByOptions(); } - _needCreateCrossScrolling() { - return false; + repaint(): void { + this._renderView(); } - _optionChanged(args) { + _optionChanged(args: any): void { const { name } = args; if (name === 'currentDate' || name === 'firstDayOfWeek') { @@ -179,8 +317,8 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { } } - _disposeCalendars() { - if (this._calendars && Array.isArray(this._calendars)) { + _disposeCalendars(): void { + if (this._calendars) { this._calendars.forEach((calendar) => { calendar?.dispose(); }); @@ -191,7 +329,7 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { } } - _dispose() { + _dispose(): void { this._disposeCalendars(); super._dispose(); } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts index d244cb125424..39e70543ec7b 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts @@ -3,28 +3,67 @@ import eventsEngine from '@js/common/core/events/core/events_engine'; import { addNamespace } from '@js/common/core/events/utils/index'; import dateLocalization from '@js/common/core/localization/date'; import registerComponent from '@js/core/component_registrator'; +import { getPublicElement } from '@js/core/element'; +import type { PropertyType } from '@js/core/index'; import $ from '@js/core/renderer'; import dateUtils from '@js/core/utils/date'; import type { WidgetProperties } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; +interface CellClickArgs { + cancel?: boolean; + cellData: { + startDate: Date; + endDate: Date; + startDateUTC?: Date; + endDateUTC?: Date; + groups?: string[]; + groupIndex?: number; + allDay?: boolean; + }; + cellElement: Element; + component?: Widget; + element?: Element; + event: Event; +} + const CALENDAR_CELL_CLASS = 'dx-calendar-cell'; const CALENDAR_OTHER_MONTH_CLASS = 'dx-calendar-other-month'; -const CALENDAR_OTHER_VIEW_CLASS = 'dx-calendar-other-view'; const YEAR_CALENDAR_LABEL_CLASS = 'dx-scheduler-year-calendar-label'; +const YEAR_CALENDAR_HAS_APPOINTMENT_CLASS = 'dx-scheduler-year-calendar-has-appointment'; const CALENDAR_DXCLICK_EVENT_NAME = addNamespace(clickEventName, 'dxYearCalendar'); interface YearCalendarProperties extends WidgetProperties { date: Date; firstDayOfWeek?: number; - onCellClick?: (e: { value: Date }) => void; + onCellClick?: (e: CellClickArgs) => void; showMonthLabel?: boolean; + hasAppointment?: (date: Date) => boolean; + getAppointmentColor?: (date: Date) => Promise; } +type YearCalendarPropertyType = + PropertyType extends never + ? never + : PropertyType | undefined; + class YearCalendar extends Widget { readonly _viewName = 'yearCalendar'; + public option(): YearCalendarProperties; + public option(options: YearCalendarProperties): void; + public option( + name: TPropertyName + ): YearCalendarPropertyType; + public option( + name: TPropertyName, + value: YearCalendarPropertyType + ): void; + public option(...args: unknown[]): YearCalendarProperties | unknown { + return super.option.apply(this, args); + } + _getDefaultOptions(): YearCalendarProperties { return { ...super._getDefaultOptions(), @@ -32,14 +71,11 @@ class YearCalendar extends Widget { firstDayOfWeek: dateLocalization.firstDayOfWeekIndex(), onCellClick: undefined, showMonthLabel: true, + hasAppointment: undefined, + getAppointmentColor: undefined, }; } - _init(): void { - super._init(); - this._render(); - } - _render(): void { this.$element().empty(); this._renderMonthLabel(); @@ -48,9 +84,10 @@ class YearCalendar extends Widget { } _renderMonthLabel(): void { - const showMonthLabel = this.option('showMonthLabel') as unknown as boolean | undefined; + const showMonthLabel = this.option('showMonthLabel'); if (showMonthLabel !== false) { - const date = this.option('date') as unknown as Date; + const date = this.option('date'); + if (!date) return; const monthNames = dateLocalization.getMonthNames(); const monthName = monthNames[date.getMonth()]; const $label = $('
') @@ -61,14 +98,14 @@ class YearCalendar extends Widget { } _renderTable(): void { - const date = this.option('date') as unknown as Date; - const firstDayOfWeek = (this.option('firstDayOfWeek') as unknown as number | undefined) ?? dateLocalization.firstDayOfWeekIndex(); + const date = this.option('date'); + if (!date) return; + const firstDayOfWeek = this.option('firstDayOfWeek') ?? dateLocalization.firstDayOfWeekIndex(); const $table = $('') .attr('role', 'grid') .attr('aria-label', `Calendar. Month ${dateLocalization.format(date, 'monthandyear')}`); - // Render header with day names const $thead = $(''); const $headerRow = $(''); const dayNames = dateLocalization.getDayNames('abbreviated'); @@ -91,11 +128,9 @@ class YearCalendar extends Widget { $thead.append($headerRow); $table.append($thead); - // Render body with days const $tbody = $(''); const days = YearCalendar._getMonthDays(date, firstDayOfWeek); - // Group days into weeks (rows) const weeks: Date[][] = []; for (let i = 0; i < days.length; i += 7) { weeks.push(days.slice(i, i + 7)); @@ -114,10 +149,26 @@ class YearCalendar extends Widget { .attr('aria-label', YearCalendar._getAriaLabel(dayDate)); if (isOtherMonth) { - $cell.addClass(CALENDAR_OTHER_MONTH_CLASS).addClass(CALENDAR_OTHER_VIEW_CLASS); + $cell.addClass(CALENDAR_OTHER_MONTH_CLASS); } const $span = $('').text(dayNumber.toString()); + + const hasAppointment = this.option('hasAppointment'); + const getAppointmentColor = this.option('getAppointmentColor'); + + if (!isOtherMonth && hasAppointment && hasAppointment(dayDate)) { + $span.addClass(YEAR_CALENDAR_HAS_APPOINTMENT_CLASS); + + if (getAppointmentColor) { + getAppointmentColor(dayDate).then((color) => { + if (color) { + $span.css('background-color', color); + } + }).catch(() => {}); + } + } + $cell.append($span); $row.append($cell); }); @@ -184,10 +235,29 @@ class YearCalendar extends Widget { if (dateValue) { const [year, month, day] = dateValue.split('/').map(Number); const clickedDate = new Date(year, month - 1, day); + const startDate = new Date(clickedDate); + startDate.setHours(0, 0, 0, 0); + const endDate = new Date(clickedDate); + endDate.setHours(23, 59, 59, 999); - const onCellClick = this.option('onCellClick') as unknown as ((e: { value: Date }) => void) | undefined; + const onCellClick = this.option('onCellClick'); if (onCellClick) { - onCellClick({ value: clickedDate }); + const cellData = { + startDate, + endDate, + startDateUTC: startDate, + endDateUTC: endDate, + groups: [], + groupIndex: 0, + allDay: true, + }; + const clickArgs: CellClickArgs = { + event: e.originalEvent || e, + cellElement: getPublicElement($cell), + cellData, + cancel: false, + }; + onCellClick(clickArgs); } } }); @@ -196,7 +266,7 @@ class YearCalendar extends Widget { _optionChanged(args: { name: string; value?: unknown; previousValue?: unknown }): void { const { name } = args; - if (name === 'date' || name === 'firstDayOfWeek' || name === 'showMonthLabel') { + if (name === 'date' || name === 'firstDayOfWeek' || name === 'showMonthLabel' || name === 'hasAppointment' || name === 'getAppointmentColor') { this._render(); } else { super._optionChanged(args); From d8a2f5b9122f2fba77101818ea86f1a35f3248ce Mon Sep 17 00:00:00 2001 From: Sergio Bur Date: Wed, 24 Dec 2025 17:04:57 +0100 Subject: [PATCH 4/4] feat: add tooltip + refactor --- .../scheduler/workspaces/m_work_space_year.ts | 209 +++++++++++------- .../scheduler/workspaces/m_year_calendar.ts | 71 +++++- 2 files changed, 192 insertions(+), 88 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts index 7ced06e5c882..4c4a13f4bc08 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_year.ts @@ -1,5 +1,4 @@ /* eslint-disable class-methods-use-this */ -import registerComponent from '@js/core/component_registrator'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { noop } from '@js/core/utils/common'; @@ -7,9 +6,15 @@ import dateUtils from '@js/core/utils/date'; import type { ViewType } from '@js/ui/scheduler'; import { formatWeekday } from '@ts/scheduler/r1/utils/index'; +import type NotifyScheduler from '../base/m_widget_notify_scheduler'; +import type Scheduler from '../m_scheduler'; +import type { SafeAppointment } from '../types'; +import type { AppointmentDataAccessor } from '../utils/data_accessor/appointment_data_accessor'; import { VIEWS } from '../utils/options/constants_view'; +import type { ResourceManager } from '../utils/resource_manager/resource_manager'; import type { ListEntity } from '../view_model/types'; import SchedulerWorkSpace from './m_work_space'; +import type { CellClickArgs, YearCalendarProperties } from './m_year_calendar'; import YearCalendar from './m_year_calendar'; const YEAR_CLASS = 'dx-scheduler-work-space-year'; @@ -71,6 +76,10 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { return { left: 0, top: 0 }; } + getScrollableContainer() { + return this.$element(); + } + _getViewStartByOptions(): Date { const currentDate = this.option('currentDate') as Date; const yearStart = new Date(currentDate.getFullYear(), 0, 1); @@ -110,18 +119,24 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { const firstDayOfWeek = this.option('firstDayOfWeek') as number; const hasAppointment = this._createHasAppointmentChecker(); const getAppointmentColor = this._createGetAppointmentColor(); + const getAppointmentsForDate = this._createGetAppointmentsForDate(); + const showAppointmentTooltip = this._createShowAppointmentTooltip(); + const hideAppointmentTooltip = this._createHideAppointmentTooltip(); for (let month = 0; month < 12; month++) { const $calendarItem = $('
').addClass(YEAR_CALENDAR_ITEM_CLASS); const monthDate = new Date(currentYear, month, 1); - const calendarOptions: any = { + const calendarOptions: YearCalendarProperties = { date: monthDate, firstDayOfWeek, showMonthLabel: true, hasAppointment, getAppointmentColor, - onCellClick: (e: any) => { + getAppointmentsForDate, + showAppointmentTooltip, + hideAppointmentTooltip, + onCellClick: (e: CellClickArgs) => { this._handleYearCalendarCellClick(e); }, }; @@ -136,89 +151,76 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { this._$dateTable.appendTo(this._$workSpace); } - _createHasAppointmentChecker(): (date: Date) => boolean { - const notifyScheduler = this.option('notifyScheduler') as any; - const scheduler = notifyScheduler?.scheduler; - - if (!scheduler) { - return () => false; - } - + _getSchedulerAndDataAccessors(): { + scheduler: Scheduler; + dataAccessors: AppointmentDataAccessor; + } { + const notifyScheduler = this.option('notifyScheduler') as NotifyScheduler; + const { scheduler } = notifyScheduler; const dataAccessors = scheduler._dataAccessors; + return { scheduler, dataAccessors }; + } - if (!dataAccessors) { - return () => false; - } - - return (date: Date): boolean => { - const dataSource = scheduler.getDataSource()?.items() || []; - - const dayStart = new Date(date); - dayStart.setHours(0, 0, 0, 0); - const dayStartTime = dayStart.getTime(); - const dayEnd = new Date(date); - dayEnd.setHours(23, 59, 59, 999); - const dayEndTime = dayEnd.getTime(); - - return dataSource.some((appointment: any) => { - const startDate = dataAccessors.get('startDate', appointment); - const endDate = dataAccessors.get('endDate', appointment); + _getDayTimeRange(date: Date): { dayStartTime: number; dayEndTime: number } { + const dayStart = new Date(date); + dayStart.setHours(0, 0, 0, 0); + const dayStartTime = dayStart.getTime(); + const dayEnd = new Date(date); + dayEnd.setHours(23, 59, 59, 999); + const dayEndTime = dayEnd.getTime(); + return { dayStartTime, dayEndTime }; + } - if (!startDate || !endDate) { - return false; - } + _appointmentIntersectsDay( + appointment: SafeAppointment, + dataAccessors: AppointmentDataAccessor, + dayStartTime: number, + dayEndTime: number, + ): boolean { + const startDate = dataAccessors.get('startDate', appointment); + const endDate = dataAccessors.get('endDate', appointment); + + const start = new Date(startDate); + const startTime = start.getTime(); + const end = new Date(endDate); + const endTime = end.getTime(); + + return startTime <= dayEndTime && endTime >= dayStartTime; + } - const start = new Date(startDate); - const startTime = start.getTime(); - const end = new Date(endDate); - const endTime = end.getTime(); + _createHasAppointmentChecker(): (date: Date) => boolean { + const { scheduler, dataAccessors } = this._getSchedulerAndDataAccessors(); - return startTime <= dayEndTime && endTime >= dayStartTime; - }); + return (date: Date): boolean => { + const dataSource = scheduler._dataSource.items() as SafeAppointment[]; + const { dayStartTime, dayEndTime } = this._getDayTimeRange(date); + + return dataSource.some((appointment: SafeAppointment) => this._appointmentIntersectsDay( + appointment, + dataAccessors, + dayStartTime, + dayEndTime, + )); }; } _createGetAppointmentColor(): (date: Date) => Promise { - const notifyScheduler = this.option('notifyScheduler') as any; - const scheduler = notifyScheduler?.scheduler; - - if (!scheduler) { - return () => Promise.resolve(undefined); - } - - const dataAccessors = scheduler._dataAccessors; - const getResourceManager = this.option('getResourceManager') as any; - - if (!dataAccessors || !getResourceManager) { - return () => Promise.resolve(undefined); - } + const { scheduler, dataAccessors } = this._getSchedulerAndDataAccessors(); + const getResourceManager = this.option('getResourceManager') as () => ResourceManager; return async (date: Date): Promise => { try { - const dataSource = scheduler.getDataSource()?.items() || []; - - const dayStart = new Date(date); - dayStart.setHours(0, 0, 0, 0); - const dayStartTime = dayStart.getTime(); - const dayEnd = new Date(date); - dayEnd.setHours(23, 59, 59, 999); - const dayEndTime = dayEnd.getTime(); - - const appointment = dataSource.find((appt: any) => { - const startDate = dataAccessors.get('startDate', appt); - const endDate = dataAccessors.get('endDate', appt); - - if (!startDate || !endDate) { - return false; - } - - const start = new Date(startDate); - const startTime = start.getTime(); - const end = new Date(endDate); - const endTime = end.getTime(); - - return startTime <= dayEndTime && endTime >= dayStartTime; - }); + const dataSource = scheduler._dataSource.items() as SafeAppointment[]; + const { dayStartTime, dayEndTime } = this._getDayTimeRange(date); + + const appointment = dataSource.find( + (appt: SafeAppointment) => this._appointmentIntersectsDay( + appt, + dataAccessors, + dayStartTime, + dayEndTime, + ), + ); if (!appointment) { return undefined; @@ -238,13 +240,59 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { }; } + _createGetAppointmentsForDate() { + const { scheduler, dataAccessors } = this._getSchedulerAndDataAccessors(); + + return (date: Date): SafeAppointment[] => { + const dataSource = scheduler._dataSource.items() as SafeAppointment[]; + const { dayStartTime, dayEndTime } = this._getDayTimeRange(date); + + return dataSource.filter((appointment: SafeAppointment) => this._appointmentIntersectsDay( + appointment, + dataAccessors, + dayStartTime, + dayEndTime, + )); + }; + } + + _createShowAppointmentTooltip() { + const { scheduler } = this._getSchedulerAndDataAccessors(); + const getResourceManager = this.option('getResourceManager') as () => ResourceManager; + + return (appointments: SafeAppointment[], target: dxElementWrapper) => { + if (!appointments || appointments.length === 0) { + return; + } + + const resourceManager = getResourceManager(); + const tooltipItems = appointments.map((appointment) => ({ + appointment, + targetedAppointment: undefined, + color: resourceManager.getAppointmentColor({ + itemData: appointment, + groupIndex: 0, + }), + })); + + scheduler.showAppointmentTooltipCore(target, tooltipItems); + }; + } + + _createHideAppointmentTooltip() { + const { scheduler } = this._getSchedulerAndDataAccessors(); + + return (): void => { + scheduler.hideAppointmentTooltip(); + }; + } + _createCellClickAction(): void { this._cellClickAction = this._createActionByOption('onCellClick', { afterExecute: (actionArgs: any) => { const args = actionArgs.args[0]; if (!args.cancel) { - const notifyScheduler = this.option('notifyScheduler') as any; - const scheduler = notifyScheduler?.scheduler; + const { scheduler } = this._getSchedulerAndDataAccessors(); if (scheduler && scheduler.showAddAppointmentPopup) { scheduler.showAddAppointmentPopup(args.cellData, args.cellData.groups || {}); } @@ -253,9 +301,8 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { }); } - _handleYearCalendarCellClick(e: any): void { - const notifyScheduler = this.option('notifyScheduler') as any; - const scheduler = notifyScheduler?.scheduler; + _handleYearCalendarCellClick(e: CellClickArgs): void { + const { scheduler } = this._getSchedulerAndDataAccessors(); const args = { cancel: false, @@ -306,7 +353,7 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { this._renderView(); } - _optionChanged(args: any): void { + _optionChanged(args: Record): void { const { name } = args; if (name === 'currentDate' || name === 'firstDayOfWeek') { @@ -335,6 +382,4 @@ class SchedulerWorkSpaceYear extends SchedulerWorkSpace { } } -registerComponent('dxSchedulerWorkSpaceYear', SchedulerWorkSpaceYear as any); - export default SchedulerWorkSpaceYear; diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts index 39e70543ec7b..1e5008c1712e 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_year_calendar.ts @@ -1,16 +1,20 @@ import { name as clickEventName } from '@js/common/core/events/click'; import eventsEngine from '@js/common/core/events/core/events_engine'; +import pointerEvents from '@js/common/core/events/pointer'; import { addNamespace } from '@js/common/core/events/utils/index'; import dateLocalization from '@js/common/core/localization/date'; import registerComponent from '@js/core/component_registrator'; import { getPublicElement } from '@js/core/element'; import type { PropertyType } from '@js/core/index'; +import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import dateUtils from '@js/core/utils/date'; import type { WidgetProperties } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; -interface CellClickArgs { +import type { SafeAppointment } from '../types'; + +export interface CellClickArgs { cancel?: boolean; cellData: { startDate: Date; @@ -33,14 +37,22 @@ const YEAR_CALENDAR_LABEL_CLASS = 'dx-scheduler-year-calendar-label'; const YEAR_CALENDAR_HAS_APPOINTMENT_CLASS = 'dx-scheduler-year-calendar-has-appointment'; const CALENDAR_DXCLICK_EVENT_NAME = addNamespace(clickEventName, 'dxYearCalendar'); +const CALENDAR_POINTERENTER_EVENT_NAME = addNamespace(pointerEvents.enter, 'dxYearCalendar'); +const CALENDAR_POINTERLEAVE_EVENT_NAME = addNamespace(pointerEvents.leave, 'dxYearCalendar'); -interface YearCalendarProperties extends WidgetProperties { +export interface YearCalendarProperties extends WidgetProperties { date: Date; firstDayOfWeek?: number; onCellClick?: (e: CellClickArgs) => void; showMonthLabel?: boolean; hasAppointment?: (date: Date) => boolean; getAppointmentColor?: (date: Date) => Promise; + getAppointmentsForDate?: (date: Date) => SafeAppointment[]; + showAppointmentTooltip?: ( + appointments: SafeAppointment[], + target: dxElementWrapper + ) => void; + hideAppointmentTooltip?: () => void; } type YearCalendarPropertyType = @@ -73,6 +85,9 @@ class YearCalendar extends Widget { showMonthLabel: true, hasAppointment: undefined, getAppointmentColor: undefined, + getAppointmentsForDate: undefined, + showAppointmentTooltip: undefined, + hideAppointmentTooltip: undefined, }; } @@ -228,6 +243,9 @@ class YearCalendar extends Widget { _renderEvents(): void { eventsEngine.off(this.$element(), CALENDAR_DXCLICK_EVENT_NAME); + eventsEngine.off(this.$element(), CALENDAR_POINTERENTER_EVENT_NAME); + eventsEngine.off(this.$element(), CALENDAR_POINTERLEAVE_EVENT_NAME); + eventsEngine.on(this.$element(), CALENDAR_DXCLICK_EVENT_NAME, `.${CALENDAR_CELL_CLASS}`, (e) => { const $cell = $(e.currentTarget); const dateValue = $cell.attr('data-value'); @@ -261,15 +279,56 @@ class YearCalendar extends Widget { } } }); + + eventsEngine.on(this.$element(), CALENDAR_POINTERENTER_EVENT_NAME, `.${CALENDAR_CELL_CLASS}`, (e) => { + const $cell = $(e.currentTarget); + const dateValue = $cell.attr('data-value'); + + if (dateValue) { + const [year, month, day] = dateValue.split('/').map(Number); + const hoveredDate = new Date(year, month - 1, day); + const calendarDate = this.option('date'); + + if (!calendarDate || hoveredDate.getMonth() !== calendarDate.getMonth()) { + return; + } + + const getAppointmentsForDate = this.option('getAppointmentsForDate'); + const showAppointmentTooltip = this.option('showAppointmentTooltip'); + + if (getAppointmentsForDate && showAppointmentTooltip) { + const appointments = getAppointmentsForDate(hoveredDate); + if (appointments && appointments.length > 0) { + showAppointmentTooltip(appointments, $cell); + } + } + } + }); + + eventsEngine.on(this.$element(), CALENDAR_POINTERLEAVE_EVENT_NAME, `.${CALENDAR_CELL_CLASS}`, () => { + const hideAppointmentTooltip = this.option('hideAppointmentTooltip'); + if (hideAppointmentTooltip) { + hideAppointmentTooltip(); + } + }); } _optionChanged(args: { name: string; value?: unknown; previousValue?: unknown }): void { const { name } = args; - if (name === 'date' || name === 'firstDayOfWeek' || name === 'showMonthLabel' || name === 'hasAppointment' || name === 'getAppointmentColor') { - this._render(); - } else { - super._optionChanged(args); + switch (name) { + case 'date': + case 'firstDayOfWeek': + case 'showMonthLabel': + case 'hasAppointment': + case 'getAppointmentColor': + case 'getAppointmentsForDate': + case 'showAppointmentTooltip': + case 'hideAppointmentTooltip': + this._render(); + break; + default: + super._optionChanged(args); } } }