diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.component.css b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.component.css new file mode 100644 index 000000000000..5e50b8f8a105 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.component.css @@ -0,0 +1,23 @@ +::ng-deep .dx-scheduler-cell-sizes-horizontal { + width: 100px; +} + +::ng-deep .dx-scheduler-group-header { + min-width: 120px; +} + +::ng-deep .dx-scheduler-group-header, +::ng-deep .dx-scheduler-header-panel-empty-cell, +::ng-deep .dx-scheduler-work-space-vertical-group-table, +::ng-deep .dx-scheduler-work-space-vertical-grouped .dx-scheduler-time-panel-cell, +::ng-deep .dx-scheduler-work-space-grouped:not(.dx-scheduler-work-space-vertical-grouped) + .dx-scheduler-header-panel-cell { + background-color: var(--dx-color-main-bg); +} + +::ng-deep .dx-scheduler-group-header, +::ng-deep .dx-scheduler-group-header .dx-scheduler-group-header-content { + font-size: 11px; + font-weight: 600; + line-height: 16px; +} diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.component.html b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.component.html new file mode 100644 index 000000000000..92d182da6bbc --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.component.html @@ -0,0 +1,36 @@ + + + + + + + diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.component.ts b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.component.ts new file mode 100644 index 000000000000..ed0e1d8f7cc6 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.component.ts @@ -0,0 +1,215 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { Component, enableProdMode, provideZoneChangeDetection } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DxSchedulerModule } from 'devextreme-angular'; +import { DxSchedulerTypes } from 'devextreme-angular/ui/scheduler'; +import { Appointment, Assignee, Service } from './app.service'; + +type Form = DxSchedulerTypes.AppointmentFormOpeningEvent['form']; +type FormItem = { + name?: string; + dataField?: string; + items?: FormItem[]; + [key: string]: unknown; +}; +type FoundItem = { list: FormItem[]; index: number } | null; + +if (!/localhost/.test(document.location.host)) { + enableProdMode(); +} + +let modulePrefix = ''; +// @ts-ignore +if (window && window.config?.packageConfigPaths) { + modulePrefix = '/app'; +} + +@Component({ + selector: 'demo-app', + templateUrl: `.${modulePrefix}/app.component.html`, + styleUrls: [`.${modulePrefix}/app.component.css`], + providers: [Service], + imports: [ + CommonModule, + DxSchedulerModule, + ], +}) +export class AppComponent { + appointments: Appointment[]; + + assignees: Assignee[]; + + rooms: Assignee[]; + + groups: string[] = ['assigneeId']; + + currentDate: Date = new Date(2026, 6, 13); + + constructor(service: Service) { + this.appointments = service.getAppointments(); + this.assignees = service.getAssignees(); + this.rooms = this.assignees.filter((item) => item.parentId === null); + } + + employeesOf(roomId: string | null): Assignee[] { + return this.assignees.filter((item) => item.parentId === roomId); + } + + roomOf(assigneeId: number | undefined): string | null { + return this.assignees.find((item) => item.id === assigneeId)?.parentId ?? null; + } + + findItem(items: FormItem[], predicate: (item: FormItem) => boolean): FoundItem { + for (let i = 0; i < items.length; i += 1) { + if (predicate(items[i])) { + return { list: items, index: i }; + } + + const nested = items[i].items; + + if (nested) { + const found = this.findItem(nested, predicate); + + if (found) { + return found; + } + } + } + + return null; + } + + createRoomGroup(form: Form, roomId: string | null): FormItem { + return { + itemType: 'group', + name: 'roomGroup', + cssClass: 'dx-scheduler-form-group-with-icon', + colCount: 2, + colCountByScreen: { xs: 2 }, + items: [ + { + colSpan: 1, + name: 'roomIcon', + cssClass: 'dx-scheduler-form-icon', + template: () => '
', + }, + { + itemType: 'simple', + name: 'roomEditor', + colSpan: 1, + label: { visible: false }, + editorType: 'dxSelectBox', + editorOptions: { + dataSource: this.rooms, + displayExpr: 'shortText', + valueExpr: 'id', + value: roomId, + placeholder: 'Room', + stylingMode: form.getEditor('assigneeId')?.option('stylingMode'), + onValueChanged: (e) => { + const editor = form.getEditor('assigneeId'); + + editor?.option('dataSource', e.value ? this.employeesOf(e.value) : []); + editor?.option('value', []); + }, + }, + }, + ], + }; + } + + renderEmployeeTag(data: { text: string; color?: string }) { + const tag = document.createElement('div'); + + tag.className = 'dx-tag-content'; + tag.style.backgroundColor = data.color ?? ''; + tag.style.borderColor = data.color ?? 'transparent'; + tag.textContent = data.text; + + const removeButton = document.createElement('div'); + + removeButton.className = 'dx-tag-remove-button'; + tag.appendChild(removeButton); + + return tag; + } + + hideLabels(items: FormItem[]) { + items.forEach((item) => { + if (item.items) { + this.hideLabels(item.items); + } else if (item.dataField !== 'allDay') { + item.label = { ...(item.label as object), visible: false }; + } + }); + } + + roomIdOf( + appointmentData: DxSchedulerTypes.AppointmentFormOpeningEvent['appointmentData'], + ): string | null { + const assigneeIds = appointmentData?.assigneeId as number | number[] | undefined; + const [assigneeId] = Array.isArray(assigneeIds) ? assigneeIds : [assigneeIds]; + + return this.roomOf(assigneeId); + } + + requireEmployee(items: FormItem[]) { + const found = this.findItem(items, (item) => item.dataField === 'assigneeId'); + + if (!found) { + return; + } + + const item = found.list[found.index]; + + item.validationRules = [{ type: 'required', message: 'Employee is required' }]; + item.editorOptions = { + ...(item.editorOptions as object), + tagTemplate: this.renderEmployeeTag, + }; + } + + hideGroup(items: FormItem[], name: string) { + const found = this.findItem(items, (item) => item.name === name); + + if (found) { + found.list[found.index].visible = false; + } + } + + onOptionChanged(e: DxSchedulerTypes.OptionChangedEvent) { + if (e.name === 'currentView') { + e.component.option('crossScrollingEnabled', e.value === 'Horizontal Grouping'); + } + } + + onAppointmentFormOpening(e: DxSchedulerTypes.AppointmentFormOpeningEvent) { + const { form } = e; + const items = form.option('items') as FormItem[]; + + if (this.findItem(items, (item) => item.name === 'roomGroup')) { + return; + } + + const roomId = this.roomIdOf(e.appointmentData); + const mainGroup = items.find((item) => item.name === 'mainGroup'); + const employee = this.findItem(items, (item) => item.name === 'assigneeIdGroup'); + const repeatValue = form.getEditor('repeatEditor')?.option('value'); + + employee?.list.splice(employee.index, 0, this.createRoomGroup(form, roomId)); + this.hideLabels(mainGroup?.items ?? []); + this.requireEmployee(items); + this.hideGroup(items, 'descriptionGroup'); + + form.option('items', items.slice()); + + form.getEditor('repeatEditor')?.option('value', repeatValue); + form.getEditor('assigneeId')?.option('dataSource', roomId ? this.employeesOf(roomId) : []); + } +} + +bootstrapApplication(AppComponent, { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }), + ], +}); diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.service.ts b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.service.ts new file mode 100644 index 000000000000..eda9a2100084 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/app/app.service.ts @@ -0,0 +1,194 @@ +import { Injectable } from '@angular/core'; + +export class Appointment { + text: string; + + assigneeId: number[]; + + startDate: Date; + + endDate: Date; +} + +export class Assignee { + id: number | string; + + text: string; + + parentId: string | null; + + shortText?: string; + + color?: string; +} + +const assignees: Assignee[] = [ + { + id: 'room-1', text: '🏢 Room 1', shortText: 'Room 1', parentId: null, + }, + { + id: 1, text: 'Samantha Bright', parentId: 'room-1', color: '#A7E3A5', + }, + { + id: 2, text: 'John Heart', parentId: 'room-1', color: '#F9E2AE', + }, + { + id: 'room-2', text: '🏢 Room 2', shortText: 'Room 2', parentId: null, + }, + { + id: 3, text: 'Todd Hoffman', parentId: 'room-2', color: '#F1BBBC', + }, + { + id: 4, text: 'Sandra Johnson', parentId: 'room-2', color: '#CFE4FA', + }, +]; + +const appointments: Appointment[] = [ + { + text: 'Upgrade Personal Computers', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, { + text: 'Install New Database', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, { + text: 'Install New Router in Dev Room', + assigneeId: [1], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 15), + }, { + text: 'Brochure Design Review', + assigneeId: [1], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 13, 30), + }, { + text: 'Book Flights to San Fran for Sales Trip', + assigneeId: [1], + startDate: new Date(2026, 6, 16, 10, 0), + endDate: new Date(2026, 6, 16, 12, 0), + }, { + text: 'Upgrade Server Hardware', + assigneeId: [1], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, { + text: 'Google AdWords Strategy', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 9, 0), + endDate: new Date(2026, 6, 13, 12, 0), + }, { + text: 'Rollout of New Website and Marketing Brochures', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, { + text: 'Update NDA Agreement', + assigneeId: [2], + startDate: new Date(2026, 6, 14, 11, 0), + endDate: new Date(2026, 6, 14, 14, 15), + }, { + text: 'Submit Questions Regarding New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 10, 0), + endDate: new Date(2026, 6, 15, 11, 30), + }, { + text: 'Submit Signed NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 13, 0), + endDate: new Date(2026, 6, 15, 15, 0), + }, { + text: 'Review Training Course for any Omissions', + assigneeId: [2], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 14, 0), + }, { + text: 'Update Employee Files with New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 11, 45), + }, { + text: 'Website Re-Design Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, { + text: 'New Brochures', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 15), + }, { + text: 'Approve Personal Computer Upgrade Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 10, 0), + endDate: new Date(2026, 6, 14, 11, 0), + }, { + text: 'Final Budget Review', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 12, 0), + endDate: new Date(2026, 6, 14, 13, 35), + }, { + text: 'Approve New Online Marketing Strategy', + assigneeId: [3], + startDate: new Date(2026, 6, 15, 12, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, { + text: 'Prepare 2026 Marketing Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 13, 30), + }, { + text: 'Create Icons for Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 10, 0), + endDate: new Date(2026, 6, 17, 11, 30), + }, { + text: 'Launch New Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 12, 20), + endDate: new Date(2026, 6, 17, 14, 0), + }, { + text: 'Comment on Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 13, 10, 0), + endDate: new Date(2026, 6, 13, 13, 0), + }, { + text: 'Approve Hiring of John Jeffers', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 0), + }, { + text: 'Non-Compete Agreements', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 13, 0), + endDate: new Date(2026, 6, 14, 15, 45), + }, { + text: 'Review Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, { + text: 'Review Changes to Health Insurance Coverage', + assigneeId: [4], + startDate: new Date(2026, 6, 16, 9, 0), + endDate: new Date(2026, 6, 16, 13, 0), + }, { + text: 'Provide New Health Insurance Docs', + assigneeId: [4], + startDate: new Date(2026, 6, 17, 12, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, +]; + +@Injectable() +export class Service { + getAppointments(): Appointment[] { + return appointments; + } + + getAssignees(): Assignee[] { + return assignees; + } +} diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/index.html b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/index.html new file mode 100644 index 000000000000..1ab1fb54a1df --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/Angular/index.html @@ -0,0 +1,26 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + + + +
+ Loading... +
+ + diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/React/App.tsx b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/App.tsx new file mode 100644 index 000000000000..5e48ffb84e92 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/App.tsx @@ -0,0 +1,212 @@ +import React, { useCallback, useState } from 'react'; + +import Scheduler, { Resource, View } from 'devextreme-react/scheduler'; +import type { SchedulerTypes } from 'devextreme-react/scheduler'; +import { appointments, assignees } from './data.ts'; + +const currentDate = new Date(2026, 6, 13); + +const groups = ['assigneeId']; + +type Form = SchedulerTypes.AppointmentFormOpeningEvent['form']; +type FormItem = { + name?: string; + dataField?: string; + items?: FormItem[]; + [key: string]: unknown; +}; +type FoundItem = { list: FormItem[]; index: number } | null; + +const rooms = assignees.filter((item) => item.parentId === null); + +const employeesOf = (roomId: string | null) => assignees + .filter((item) => item.parentId === roomId); + +const roomOf = (assigneeId: number | undefined): string | null => assignees + .find((item) => item.id === assigneeId)?.parentId ?? null; + +const findItem = (items: FormItem[], predicate: (item: FormItem) => boolean): FoundItem => { + for (let i = 0; i < items.length; i += 1) { + if (predicate(items[i])) { + return { list: items, index: i }; + } + + const nested = items[i].items; + + if (nested) { + const found = findItem(nested, predicate); + + if (found) { + return found; + } + } + } + + return null; +}; + +const createRoomGroup = (form: Form, roomId: string | null): FormItem => ({ + itemType: 'group', + name: 'roomGroup', + cssClass: 'dx-scheduler-form-group-with-icon', + colCount: 2, + colCountByScreen: { xs: 2 }, + items: [ + { + colSpan: 1, + name: 'roomIcon', + cssClass: 'dx-scheduler-form-icon', + template: () => '
', + }, + { + itemType: 'simple', + name: 'roomEditor', + colSpan: 1, + label: { visible: false }, + editorType: 'dxSelectBox', + editorOptions: { + dataSource: rooms, + displayExpr: 'shortText', + valueExpr: 'id', + value: roomId, + placeholder: 'Room', + stylingMode: form.getEditor('assigneeId')?.option('stylingMode'), + onValueChanged(e: { value: string }) { + const editor = form.getEditor('assigneeId'); + + editor?.option('dataSource', e.value ? employeesOf(e.value) : []); + editor?.option('value', []); + }, + }, + }, + ], +}); + +const renderEmployeeTag = (data: { text: string; color?: string }) => { + const tag = document.createElement('div'); + + tag.className = 'dx-tag-content'; + tag.style.backgroundColor = data.color ?? ''; + tag.style.borderColor = data.color ?? 'transparent'; + tag.textContent = data.text; + + const removeButton = document.createElement('div'); + + removeButton.className = 'dx-tag-remove-button'; + tag.appendChild(removeButton); + + return tag; +}; + +const hideLabels = (items: FormItem[]) => { + items.forEach((item) => { + if (item.items) { + hideLabels(item.items); + } else if (item.dataField !== 'allDay') { + item.label = { ...(item.label as object), visible: false }; + } + }); +}; + +const roomIdOf = ( + appointmentData: SchedulerTypes.AppointmentFormOpeningEvent['appointmentData'], +): string | null => { + const assigneeIds = appointmentData?.assigneeId as number | number[] | undefined; + const [assigneeId] = Array.isArray(assigneeIds) ? assigneeIds : [assigneeIds]; + + return roomOf(assigneeId); +}; + +const requireEmployee = (items: FormItem[]) => { + const found = findItem(items, (item) => item.dataField === 'assigneeId'); + + if (!found) { + return; + } + + const item = found.list[found.index]; + + item.validationRules = [{ type: 'required', message: 'Employee is required' }]; + item.editorOptions = { ...(item.editorOptions as object), tagTemplate: renderEmployeeTag }; +}; + +const hideGroup = (items: FormItem[], name: string) => { + const found = findItem(items, (item) => item.name === name); + + if (found) { + found.list[found.index].visible = false; + } +}; + +const onAppointmentFormOpening = (e: SchedulerTypes.AppointmentFormOpeningEvent) => { + const { form } = e; + const items = form.option('items') as FormItem[]; + + if (findItem(items, (item) => item.name === 'roomGroup')) { + return; + } + + const roomId = roomIdOf(e.appointmentData); + const mainGroup = items.find((item) => item.name === 'mainGroup'); + const employee = findItem(items, (item) => item.name === 'assigneeIdGroup'); + const repeatValue = form.getEditor('repeatEditor')?.option('value'); + + employee?.list.splice(employee.index, 0, createRoomGroup(form, roomId)); + hideLabels(mainGroup?.items ?? []); + requireEmployee(items); + hideGroup(items, 'descriptionGroup'); + + form.option('items', items.slice()); + + form.getEditor('repeatEditor')?.option('value', repeatValue); + form.getEditor('assigneeId')?.option('dataSource', roomId ? employeesOf(roomId) : []); +}; + +const App = () => { + const [crossScrollingEnabled, setCrossScrollingEnabled] = useState(false); + + const onOptionChanged = useCallback((e: SchedulerTypes.OptionChangedEvent) => { + if (e.name === 'currentView') { + setCrossScrollingEnabled(e.value === 'Horizontal Grouping'); + } + }, []); + + return ( + + + + + + ); +}; + +export default App; diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/React/data.ts b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/data.ts new file mode 100644 index 000000000000..03e0ea45df7a --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/data.ts @@ -0,0 +1,171 @@ +import type { SchedulerTypes } from 'devextreme-react/scheduler'; + +type Appointment = SchedulerTypes.Appointment & { assigneeId: number[] }; + +type Assignee = { + id: number | string; + text: string; + parentId: string | null; + shortText?: string; + color?: string; +}; + +export const assignees: Assignee[] = [ + { + id: 'room-1', text: '🏢 Room 1', shortText: 'Room 1', parentId: null, + }, + { + id: 1, text: 'Samantha Bright', parentId: 'room-1', color: '#A7E3A5', + }, + { + id: 2, text: 'John Heart', parentId: 'room-1', color: '#F9E2AE', + }, + { + id: 'room-2', text: '🏢 Room 2', shortText: 'Room 2', parentId: null, + }, + { + id: 3, text: 'Todd Hoffman', parentId: 'room-2', color: '#F1BBBC', + }, + { + id: 4, text: 'Sandra Johnson', parentId: 'room-2', color: '#CFE4FA', + }, +]; + +export const appointments: Appointment[] = [ + { + text: 'Upgrade Personal Computers', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, { + text: 'Install New Database', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, { + text: 'Install New Router in Dev Room', + assigneeId: [1], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 15), + }, { + text: 'Brochure Design Review', + assigneeId: [1], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 13, 30), + }, { + text: 'Book Flights to San Fran for Sales Trip', + assigneeId: [1], + startDate: new Date(2026, 6, 16, 10, 0), + endDate: new Date(2026, 6, 16, 12, 0), + }, { + text: 'Upgrade Server Hardware', + assigneeId: [1], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, { + text: 'Google AdWords Strategy', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 9, 0), + endDate: new Date(2026, 6, 13, 12, 0), + }, { + text: 'Rollout of New Website and Marketing Brochures', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, { + text: 'Update NDA Agreement', + assigneeId: [2], + startDate: new Date(2026, 6, 14, 11, 0), + endDate: new Date(2026, 6, 14, 14, 15), + }, { + text: 'Submit Questions Regarding New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 10, 0), + endDate: new Date(2026, 6, 15, 11, 30), + }, { + text: 'Submit Signed NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 13, 0), + endDate: new Date(2026, 6, 15, 15, 0), + }, { + text: 'Review Training Course for any Omissions', + assigneeId: [2], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 14, 0), + }, { + text: 'Update Employee Files with New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 11, 45), + }, { + text: 'Website Re-Design Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, { + text: 'New Brochures', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 15), + }, { + text: 'Approve Personal Computer Upgrade Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 10, 0), + endDate: new Date(2026, 6, 14, 11, 0), + }, { + text: 'Final Budget Review', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 12, 0), + endDate: new Date(2026, 6, 14, 13, 35), + }, { + text: 'Approve New Online Marketing Strategy', + assigneeId: [3], + startDate: new Date(2026, 6, 15, 12, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, { + text: 'Prepare 2026 Marketing Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 13, 30), + }, { + text: 'Create Icons for Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 10, 0), + endDate: new Date(2026, 6, 17, 11, 30), + }, { + text: 'Launch New Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 12, 20), + endDate: new Date(2026, 6, 17, 14, 0), + }, { + text: 'Comment on Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 13, 10, 0), + endDate: new Date(2026, 6, 13, 13, 0), + }, { + text: 'Approve Hiring of John Jeffers', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 0), + }, { + text: 'Non-Compete Agreements', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 13, 0), + endDate: new Date(2026, 6, 14, 15, 45), + }, { + text: 'Review Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, { + text: 'Review Changes to Health Insurance Coverage', + assigneeId: [4], + startDate: new Date(2026, 6, 16, 9, 0), + endDate: new Date(2026, 6, 16, 13, 0), + }, { + text: 'Provide New Health Insurance Docs', + assigneeId: [4], + startDate: new Date(2026, 6, 17, 12, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, +]; diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/React/index.html b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/index.html new file mode 100644 index 000000000000..ee451f8288ff --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/index.html @@ -0,0 +1,24 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/React/index.tsx b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/index.tsx new file mode 100644 index 000000000000..8acbec4b6179 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import App from './App.tsx'; + +ReactDOM.render( + , + document.getElementById('app'), +); diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/React/styles.css b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/styles.css new file mode 100644 index 000000000000..2fdf6823a33d --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/React/styles.css @@ -0,0 +1,23 @@ +.dx-scheduler-cell-sizes-horizontal { + width: 100px; +} + +.dx-scheduler-group-header { + min-width: 120px; +} + +.dx-scheduler-group-header, +.dx-scheduler-header-panel-empty-cell, +.dx-scheduler-work-space-vertical-group-table, +.dx-scheduler-work-space-vertical-grouped .dx-scheduler-time-panel-cell, +.dx-scheduler-work-space-grouped:not(.dx-scheduler-work-space-vertical-grouped) + .dx-scheduler-header-panel-cell { + background-color: var(--dx-color-main-bg); +} + +.dx-scheduler-group-header, +.dx-scheduler-group-header .dx-scheduler-group-header-content { + font-size: 11px; + font-weight: 600; + line-height: 16px; +} diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/App.js b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/App.js new file mode 100644 index 000000000000..591be721b788 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/App.js @@ -0,0 +1,162 @@ +import React, { useCallback, useState } from 'react'; +import Scheduler, { Resource, View } from 'devextreme-react/scheduler'; +import { appointments, assignees } from './data.js'; + +const currentDate = new Date(2026, 6, 13); +const groups = ['assigneeId']; +const rooms = assignees.filter((item) => item.parentId === null); +const employeesOf = (roomId) => assignees.filter((item) => item.parentId === roomId); +const roomOf = (assigneeId) => assignees.find((item) => item.id === assigneeId)?.parentId ?? null; +const findItem = (items, predicate) => { + for (let i = 0; i < items.length; i += 1) { + if (predicate(items[i])) { + return { list: items, index: i }; + } + const nested = items[i].items; + if (nested) { + const found = findItem(nested, predicate); + if (found) { + return found; + } + } + } + return null; +}; +const createRoomGroup = (form, roomId) => ({ + itemType: 'group', + name: 'roomGroup', + cssClass: 'dx-scheduler-form-group-with-icon', + colCount: 2, + colCountByScreen: { xs: 2 }, + items: [ + { + colSpan: 1, + name: 'roomIcon', + cssClass: 'dx-scheduler-form-icon', + template: () => '
', + }, + { + itemType: 'simple', + name: 'roomEditor', + colSpan: 1, + label: { visible: false }, + editorType: 'dxSelectBox', + editorOptions: { + dataSource: rooms, + displayExpr: 'shortText', + valueExpr: 'id', + value: roomId, + placeholder: 'Room', + stylingMode: form.getEditor('assigneeId')?.option('stylingMode'), + onValueChanged(e) { + const editor = form.getEditor('assigneeId'); + editor?.option('dataSource', e.value ? employeesOf(e.value) : []); + editor?.option('value', []); + }, + }, + }, + ], +}); +const renderEmployeeTag = (data) => { + const tag = document.createElement('div'); + tag.className = 'dx-tag-content'; + tag.style.backgroundColor = data.color ?? ''; + tag.style.borderColor = data.color ?? 'transparent'; + tag.textContent = data.text; + const removeButton = document.createElement('div'); + removeButton.className = 'dx-tag-remove-button'; + tag.appendChild(removeButton); + return tag; +}; +const hideLabels = (items) => { + items.forEach((item) => { + if (item.items) { + hideLabels(item.items); + } else if (item.dataField !== 'allDay') { + item.label = { ...item.label, visible: false }; + } + }); +}; +const roomIdOf = (appointmentData) => { + const assigneeIds = appointmentData?.assigneeId; + const [assigneeId] = Array.isArray(assigneeIds) ? assigneeIds : [assigneeIds]; + return roomOf(assigneeId); +}; +const requireEmployee = (items) => { + const found = findItem(items, (item) => item.dataField === 'assigneeId'); + if (!found) { + return; + } + const item = found.list[found.index]; + item.validationRules = [{ type: 'required', message: 'Employee is required' }]; + item.editorOptions = { ...item.editorOptions, tagTemplate: renderEmployeeTag }; +}; +const hideGroup = (items, name) => { + const found = findItem(items, (item) => item.name === name); + if (found) { + found.list[found.index].visible = false; + } +}; +const onAppointmentFormOpening = (e) => { + const { form } = e; + const items = form.option('items'); + if (findItem(items, (item) => item.name === 'roomGroup')) { + return; + } + const roomId = roomIdOf(e.appointmentData); + const mainGroup = items.find((item) => item.name === 'mainGroup'); + const employee = findItem(items, (item) => item.name === 'assigneeIdGroup'); + const repeatValue = form.getEditor('repeatEditor')?.option('value'); + employee?.list.splice(employee.index, 0, createRoomGroup(form, roomId)); + hideLabels(mainGroup?.items ?? []); + requireEmployee(items); + hideGroup(items, 'descriptionGroup'); + form.option('items', items.slice()); + form.getEditor('repeatEditor')?.option('value', repeatValue); + form.getEditor('assigneeId')?.option('dataSource', roomId ? employeesOf(roomId) : []); +}; +const App = () => { + const [crossScrollingEnabled, setCrossScrollingEnabled] = useState(false); + const onOptionChanged = useCallback((e) => { + if (e.name === 'currentView') { + setCrossScrollingEnabled(e.value === 'Horizontal Grouping'); + } + }, []); + return ( + + + + + + ); +}; +export default App; diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/data.js b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/data.js new file mode 100644 index 000000000000..4c73285e8f2e --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/data.js @@ -0,0 +1,202 @@ +export const assignees = [ + { + id: 'room-1', + text: '🏢 Room 1', + shortText: 'Room 1', + parentId: null, + }, + { + id: 1, + text: 'Samantha Bright', + parentId: 'room-1', + color: '#A7E3A5', + }, + { + id: 2, + text: 'John Heart', + parentId: 'room-1', + color: '#F9E2AE', + }, + { + id: 'room-2', + text: '🏢 Room 2', + shortText: 'Room 2', + parentId: null, + }, + { + id: 3, + text: 'Todd Hoffman', + parentId: 'room-2', + color: '#F1BBBC', + }, + { + id: 4, + text: 'Sandra Johnson', + parentId: 'room-2', + color: '#CFE4FA', + }, +]; +export const appointments = [ + { + text: 'Upgrade Personal Computers', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, + { + text: 'Install New Database', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, + { + text: 'Install New Router in Dev Room', + assigneeId: [1], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 15), + }, + { + text: 'Brochure Design Review', + assigneeId: [1], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 13, 30), + }, + { + text: 'Book Flights to San Fran for Sales Trip', + assigneeId: [1], + startDate: new Date(2026, 6, 16, 10, 0), + endDate: new Date(2026, 6, 16, 12, 0), + }, + { + text: 'Upgrade Server Hardware', + assigneeId: [1], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, + { + text: 'Google AdWords Strategy', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 9, 0), + endDate: new Date(2026, 6, 13, 12, 0), + }, + { + text: 'Rollout of New Website and Marketing Brochures', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, + { + text: 'Update NDA Agreement', + assigneeId: [2], + startDate: new Date(2026, 6, 14, 11, 0), + endDate: new Date(2026, 6, 14, 14, 15), + }, + { + text: 'Submit Questions Regarding New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 10, 0), + endDate: new Date(2026, 6, 15, 11, 30), + }, + { + text: 'Submit Signed NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 13, 0), + endDate: new Date(2026, 6, 15, 15, 0), + }, + { + text: 'Review Training Course for any Omissions', + assigneeId: [2], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 14, 0), + }, + { + text: 'Update Employee Files with New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 11, 45), + }, + { + text: 'Website Re-Design Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, + { + text: 'New Brochures', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 15), + }, + { + text: 'Approve Personal Computer Upgrade Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 10, 0), + endDate: new Date(2026, 6, 14, 11, 0), + }, + { + text: 'Final Budget Review', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 12, 0), + endDate: new Date(2026, 6, 14, 13, 35), + }, + { + text: 'Approve New Online Marketing Strategy', + assigneeId: [3], + startDate: new Date(2026, 6, 15, 12, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, + { + text: 'Prepare 2026 Marketing Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 13, 30), + }, + { + text: 'Create Icons for Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 10, 0), + endDate: new Date(2026, 6, 17, 11, 30), + }, + { + text: 'Launch New Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 12, 20), + endDate: new Date(2026, 6, 17, 14, 0), + }, + { + text: 'Comment on Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 13, 10, 0), + endDate: new Date(2026, 6, 13, 13, 0), + }, + { + text: 'Approve Hiring of John Jeffers', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 0), + }, + { + text: 'Non-Compete Agreements', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 13, 0), + endDate: new Date(2026, 6, 14, 15, 45), + }, + { + text: 'Review Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, + { + text: 'Review Changes to Health Insurance Coverage', + assigneeId: [4], + startDate: new Date(2026, 6, 16, 9, 0), + endDate: new Date(2026, 6, 16, 13, 0), + }, + { + text: 'Provide New Health Insurance Docs', + assigneeId: [4], + startDate: new Date(2026, 6, 17, 12, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, +]; diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/index.html b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/index.html new file mode 100644 index 000000000000..db31b0fd60c6 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/index.html @@ -0,0 +1,44 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/index.js b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/index.js new file mode 100644 index 000000000000..b853e0be8242 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/index.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App.js'; + +ReactDOM.render(, document.getElementById('app')); diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/styles.css b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/styles.css new file mode 100644 index 000000000000..2fdf6823a33d --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/ReactJs/styles.css @@ -0,0 +1,23 @@ +.dx-scheduler-cell-sizes-horizontal { + width: 100px; +} + +.dx-scheduler-group-header { + min-width: 120px; +} + +.dx-scheduler-group-header, +.dx-scheduler-header-panel-empty-cell, +.dx-scheduler-work-space-vertical-group-table, +.dx-scheduler-work-space-vertical-grouped .dx-scheduler-time-panel-cell, +.dx-scheduler-work-space-grouped:not(.dx-scheduler-work-space-vertical-grouped) + .dx-scheduler-header-panel-cell { + background-color: var(--dx-color-main-bg); +} + +.dx-scheduler-group-header, +.dx-scheduler-group-header .dx-scheduler-group-header-content { + font-size: 11px; + font-weight: 600; + line-height: 16px; +} diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/App.vue b/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/App.vue new file mode 100644 index 000000000000..f175c07e53f6 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/App.vue @@ -0,0 +1,231 @@ + + + + diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/data.ts b/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/data.ts new file mode 100644 index 000000000000..6a9ffea09cb4 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/data.ts @@ -0,0 +1,159 @@ +export const assignees = [ + { + id: 'room-1', text: '🏢 Room 1', shortText: 'Room 1', parentId: null, + }, + { + id: 1, text: 'Samantha Bright', parentId: 'room-1', color: '#A7E3A5', + }, + { + id: 2, text: 'John Heart', parentId: 'room-1', color: '#F9E2AE', + }, + { + id: 'room-2', text: '🏢 Room 2', shortText: 'Room 2', parentId: null, + }, + { + id: 3, text: 'Todd Hoffman', parentId: 'room-2', color: '#F1BBBC', + }, + { + id: 4, text: 'Sandra Johnson', parentId: 'room-2', color: '#CFE4FA', + }, +]; + +export const appointments = [ + { + text: 'Upgrade Personal Computers', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, { + text: 'Install New Database', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, { + text: 'Install New Router in Dev Room', + assigneeId: [1], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 15), + }, { + text: 'Brochure Design Review', + assigneeId: [1], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 13, 30), + }, { + text: 'Book Flights to San Fran for Sales Trip', + assigneeId: [1], + startDate: new Date(2026, 6, 16, 10, 0), + endDate: new Date(2026, 6, 16, 12, 0), + }, { + text: 'Upgrade Server Hardware', + assigneeId: [1], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, { + text: 'Google AdWords Strategy', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 9, 0), + endDate: new Date(2026, 6, 13, 12, 0), + }, { + text: 'Rollout of New Website and Marketing Brochures', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, { + text: 'Update NDA Agreement', + assigneeId: [2], + startDate: new Date(2026, 6, 14, 11, 0), + endDate: new Date(2026, 6, 14, 14, 15), + }, { + text: 'Submit Questions Regarding New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 10, 0), + endDate: new Date(2026, 6, 15, 11, 30), + }, { + text: 'Submit Signed NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 13, 0), + endDate: new Date(2026, 6, 15, 15, 0), + }, { + text: 'Review Training Course for any Omissions', + assigneeId: [2], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 14, 0), + }, { + text: 'Update Employee Files with New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 11, 45), + }, { + text: 'Website Re-Design Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, { + text: 'New Brochures', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 15), + }, { + text: 'Approve Personal Computer Upgrade Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 10, 0), + endDate: new Date(2026, 6, 14, 11, 0), + }, { + text: 'Final Budget Review', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 12, 0), + endDate: new Date(2026, 6, 14, 13, 35), + }, { + text: 'Approve New Online Marketing Strategy', + assigneeId: [3], + startDate: new Date(2026, 6, 15, 12, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, { + text: 'Prepare 2026 Marketing Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 13, 30), + }, { + text: 'Create Icons for Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 10, 0), + endDate: new Date(2026, 6, 17, 11, 30), + }, { + text: 'Launch New Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 12, 20), + endDate: new Date(2026, 6, 17, 14, 0), + }, { + text: 'Comment on Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 13, 10, 0), + endDate: new Date(2026, 6, 13, 13, 0), + }, { + text: 'Approve Hiring of John Jeffers', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 0), + }, { + text: 'Non-Compete Agreements', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 13, 0), + endDate: new Date(2026, 6, 14, 15, 45), + }, { + text: 'Review Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, { + text: 'Review Changes to Health Insurance Coverage', + assigneeId: [4], + startDate: new Date(2026, 6, 16, 9, 0), + endDate: new Date(2026, 6, 16, 13, 0), + }, { + text: 'Provide New Health Insurance Docs', + assigneeId: [4], + startDate: new Date(2026, 6, 17, 12, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, +]; diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/index.html b/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/index.html new file mode 100644 index 000000000000..c90687f1390f --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/index.html @@ -0,0 +1,27 @@ + + + + DevExtreme Demo + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/index.ts b/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/index.ts new file mode 100644 index 000000000000..684d04215d72 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/Vue/index.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +createApp(App).mount('#app'); diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/data.js b/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/data.js new file mode 100644 index 000000000000..fddf43749178 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/data.js @@ -0,0 +1,159 @@ +const assignees = [ + { + id: 'room-1', text: '🏢 Room 1', shortText: 'Room 1', parentId: null, + }, + { + id: 1, text: 'Samantha Bright', parentId: 'room-1', color: '#A7E3A5', + }, + { + id: 2, text: 'John Heart', parentId: 'room-1', color: '#F9E2AE', + }, + { + id: 'room-2', text: '🏢 Room 2', shortText: 'Room 2', parentId: null, + }, + { + id: 3, text: 'Todd Hoffman', parentId: 'room-2', color: '#F1BBBC', + }, + { + id: 4, text: 'Sandra Johnson', parentId: 'room-2', color: '#CFE4FA', + }, +]; + +const appointments = [ + { + text: 'Upgrade Personal Computers', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, { + text: 'Install New Database', + assigneeId: [1], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, { + text: 'Install New Router in Dev Room', + assigneeId: [1], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 15), + }, { + text: 'Brochure Design Review', + assigneeId: [1], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 13, 30), + }, { + text: 'Book Flights to San Fran for Sales Trip', + assigneeId: [1], + startDate: new Date(2026, 6, 16, 10, 0), + endDate: new Date(2026, 6, 16, 12, 0), + }, { + text: 'Upgrade Server Hardware', + assigneeId: [1], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, { + text: 'Google AdWords Strategy', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 9, 0), + endDate: new Date(2026, 6, 13, 12, 0), + }, { + text: 'Rollout of New Website and Marketing Brochures', + assigneeId: [2], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 30), + }, { + text: 'Update NDA Agreement', + assigneeId: [2], + startDate: new Date(2026, 6, 14, 11, 0), + endDate: new Date(2026, 6, 14, 14, 15), + }, { + text: 'Submit Questions Regarding New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 10, 0), + endDate: new Date(2026, 6, 15, 11, 30), + }, { + text: 'Submit Signed NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 15, 13, 0), + endDate: new Date(2026, 6, 15, 15, 0), + }, { + text: 'Review Training Course for any Omissions', + assigneeId: [2], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 14, 0), + }, { + text: 'Update Employee Files with New NDA', + assigneeId: [2], + startDate: new Date(2026, 6, 17, 9, 0), + endDate: new Date(2026, 6, 17, 11, 45), + }, { + text: 'Website Re-Design Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 9, 30), + endDate: new Date(2026, 6, 13, 11, 30), + }, { + text: 'New Brochures', + assigneeId: [3], + startDate: new Date(2026, 6, 13, 13, 0), + endDate: new Date(2026, 6, 13, 15, 15), + }, { + text: 'Approve Personal Computer Upgrade Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 10, 0), + endDate: new Date(2026, 6, 14, 11, 0), + }, { + text: 'Final Budget Review', + assigneeId: [3], + startDate: new Date(2026, 6, 14, 12, 0), + endDate: new Date(2026, 6, 14, 13, 35), + }, { + text: 'Approve New Online Marketing Strategy', + assigneeId: [3], + startDate: new Date(2026, 6, 15, 12, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, { + text: 'Prepare 2026 Marketing Plan', + assigneeId: [3], + startDate: new Date(2026, 6, 16, 11, 0), + endDate: new Date(2026, 6, 16, 13, 30), + }, { + text: 'Create Icons for Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 10, 0), + endDate: new Date(2026, 6, 17, 11, 30), + }, { + text: 'Launch New Website', + assigneeId: [3], + startDate: new Date(2026, 6, 17, 12, 20), + endDate: new Date(2026, 6, 17, 14, 0), + }, { + text: 'Comment on Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 13, 10, 0), + endDate: new Date(2026, 6, 13, 13, 0), + }, { + text: 'Approve Hiring of John Jeffers', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 9, 0), + endDate: new Date(2026, 6, 14, 12, 0), + }, { + text: 'Non-Compete Agreements', + assigneeId: [4], + startDate: new Date(2026, 6, 14, 13, 0), + endDate: new Date(2026, 6, 14, 15, 45), + }, { + text: 'Review Revenue Projections', + assigneeId: [4], + startDate: new Date(2026, 6, 15, 11, 0), + endDate: new Date(2026, 6, 15, 14, 0), + }, { + text: 'Review Changes to Health Insurance Coverage', + assigneeId: [4], + startDate: new Date(2026, 6, 16, 9, 0), + endDate: new Date(2026, 6, 16, 13, 0), + }, { + text: 'Provide New Health Insurance Docs', + assigneeId: [4], + startDate: new Date(2026, 6, 17, 12, 0), + endDate: new Date(2026, 6, 17, 15, 0), + }, +]; diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/index.html b/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/index.html new file mode 100644 index 000000000000..2ae8d3e24cc1 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/index.html @@ -0,0 +1,20 @@ + + + + DevExtreme Demo + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/index.js b/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/index.js new file mode 100644 index 000000000000..4688be6bd2a7 --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/index.js @@ -0,0 +1,181 @@ +const rooms = assignees.filter((item) => item.parentId === null); + +const employeesOf = (roomId) => assignees.filter((item) => item.parentId === roomId); + +const roomOf = (assigneeId) => { + const employee = assignees.find((item) => item.id === assigneeId); + + return employee ? employee.parentId : null; +}; + +const findItem = (items, predicate) => { + for (let i = 0; i < items.length; i += 1) { + if (predicate(items[i])) { + return { list: items, index: i }; + } + + if (items[i].items) { + const found = findItem(items[i].items, predicate); + + if (found) { + return found; + } + } + } + + return null; +}; + +const createRoomGroup = (form, roomId) => ({ + itemType: 'group', + name: 'roomGroup', + cssClass: 'dx-scheduler-form-group-with-icon', + colCount: 2, + colCountByScreen: { xs: 2 }, + items: [ + { + colSpan: 1, + name: 'roomIcon', + cssClass: 'dx-scheduler-form-icon', + template: () => $('
').addClass('dx-icon dx-icon-conferenceroomoutline'), + }, + { + itemType: 'simple', + name: 'roomEditor', + colSpan: 1, + label: { visible: false }, + editorType: 'dxSelectBox', + editorOptions: { + dataSource: rooms, + displayExpr: 'shortText', + valueExpr: 'id', + value: roomId, + placeholder: 'Room', + stylingMode: form.getEditor('assigneeId')?.option('stylingMode'), + onValueChanged(e) { + const editor = form.getEditor('assigneeId'); + + editor?.option('dataSource', e.value ? employeesOf(e.value) : []); + editor?.option('value', []); + }, + }, + }, + ], +}); + +const renderEmployeeTag = (data) => { + const tag = document.createElement('div'); + + tag.className = 'dx-tag-content'; + tag.style.backgroundColor = data.color ?? ''; + tag.style.borderColor = data.color ?? 'transparent'; + tag.textContent = data.text; + + const removeButton = document.createElement('div'); + + removeButton.className = 'dx-tag-remove-button'; + tag.appendChild(removeButton); + + return tag; +}; + +const hideLabels = (items) => { + items.forEach((item) => { + if (item.items) { + hideLabels(item.items); + } else if (item.dataField !== 'allDay') { + item.label = { ...item.label, visible: false }; + } + }); +}; + +const roomIdOf = (appointmentData) => { + const assigneeIds = appointmentData?.assigneeId; + const [assigneeId] = Array.isArray(assigneeIds) ? assigneeIds : [assigneeIds]; + + return roomOf(assigneeId); +}; + +const requireEmployee = (items) => { + const found = findItem(items, (item) => item.dataField === 'assigneeId'); + + if (!found) { + return; + } + + const item = found.list[found.index]; + + item.validationRules = [{ type: 'required', message: 'Employee is required' }]; + item.editorOptions = { ...item.editorOptions, tagTemplate: renderEmployeeTag }; +}; + +const hideGroup = (items, name) => { + const found = findItem(items, (item) => item.name === name); + + if (found) { + found.list[found.index].visible = false; + } +}; + +$(() => { + $('#scheduler').dxScheduler({ + dataSource: appointments, + views: [{ + type: 'workWeek', + name: 'Vertical Grouping', + groupOrientation: 'vertical', + cellDuration: 60, + }, { + type: 'workWeek', + name: 'Horizontal Grouping', + groupOrientation: 'horizontal', + }], + currentView: 'Vertical Grouping', + currentDate: new Date(2026, 6, 13), + startDayHour: 9, + endDayHour: 16, + groups: ['assigneeId'], + resources: [ + { + fieldExpr: 'assigneeId', + dataSource: assignees, + parentIdExpr: 'parentId', + label: 'Employee', + allowMultiple: true, + icon: 'user', + }, + ], + crossScrollingEnabled: false, + showAllDayPanel: false, + showCurrentTimeIndicator: false, + height: 700, + onOptionChanged(e) { + if (e.name === 'currentView') { + e.component.option('crossScrollingEnabled', e.value === 'Horizontal Grouping'); + } + }, + onAppointmentFormOpening(e) { + const { form } = e; + const items = form.option('items'); + + if (findItem(items, (item) => item.name === 'roomGroup')) { + return; + } + + const roomId = roomIdOf(e.appointmentData); + const mainGroup = items.find((item) => item.name === 'mainGroup'); + const employee = findItem(items, (item) => item.name === 'assigneeIdGroup'); + const repeatValue = form.getEditor('repeatEditor')?.option('value'); + + employee?.list.splice(employee.index, 0, createRoomGroup(form, roomId)); + hideLabels(mainGroup?.items ?? []); + requireEmployee(items); + hideGroup(items, 'descriptionGroup'); + + form.option('items', items.slice()); + + form.getEditor('repeatEditor')?.option('value', repeatValue); + form.getEditor('assigneeId')?.option('dataSource', roomId ? employeesOf(roomId) : []); + }, + }); +}); diff --git a/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/styles.css b/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/styles.css new file mode 100644 index 000000000000..2fdf6823a33d --- /dev/null +++ b/apps/demos/Demos/Scheduler/MultilevelGrouping/jQuery/styles.css @@ -0,0 +1,23 @@ +.dx-scheduler-cell-sizes-horizontal { + width: 100px; +} + +.dx-scheduler-group-header { + min-width: 120px; +} + +.dx-scheduler-group-header, +.dx-scheduler-header-panel-empty-cell, +.dx-scheduler-work-space-vertical-group-table, +.dx-scheduler-work-space-vertical-grouped .dx-scheduler-time-panel-cell, +.dx-scheduler-work-space-grouped:not(.dx-scheduler-work-space-vertical-grouped) + .dx-scheduler-header-panel-cell { + background-color: var(--dx-color-main-bg); +} + +.dx-scheduler-group-header, +.dx-scheduler-group-header .dx-scheduler-group-header-content { + font-size: 11px; + font-weight: 600; + line-height: 16px; +} diff --git a/apps/demos/menuMeta.json b/apps/demos/menuMeta.json index f761c41660c8..a30456d15cfa 100644 --- a/apps/demos/menuMeta.json +++ b/apps/demos/menuMeta.json @@ -3978,6 +3978,12 @@ "/Models/SampleData/GroupByDateTasks.cs" ], "DemoType": "Web" + }, + { + "Title": "Multilevel Grouping", + "Name": "MultilevelGrouping", + "Widget": "Scheduler", + "DemoType": "Web" } ] }, diff --git a/apps/demos/testing/etalons/Scheduler-MultilevelGrouping (fluent.blue.light).png b/apps/demos/testing/etalons/Scheduler-MultilevelGrouping (fluent.blue.light).png new file mode 100644 index 000000000000..089784244266 Binary files /dev/null and b/apps/demos/testing/etalons/Scheduler-MultilevelGrouping (fluent.blue.light).png differ diff --git a/apps/demos/testing/etalons/Scheduler-MultilevelGrouping (material.blue.light).png b/apps/demos/testing/etalons/Scheduler-MultilevelGrouping (material.blue.light).png new file mode 100644 index 000000000000..6fe5dc8aad5f Binary files /dev/null and b/apps/demos/testing/etalons/Scheduler-MultilevelGrouping (material.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/accessibility/scheduler/scheduler.ts b/e2e/testcafe-devextreme/tests/accessibility/scheduler/scheduler.ts index 745573effa48..7ba23fe3582b 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/scheduler/scheduler.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/scheduler/scheduler.ts @@ -69,3 +69,58 @@ test('Scheduler table elements have right aria attributes', async (t) => { }); }); }); + +const groupedRooms = [ + { id: 'building-a', text: 'Building A', parentId: null }, + { id: 'floor-a1', text: 'Floor 1', parentId: 'building-a' }, + { id: 101, text: 'Room 101', parentId: 'floor-a1' }, + { id: 102, text: 'Room 102', parentId: 'floor-a1' }, + { id: 'building-b', text: 'Building B', parentId: null }, + { id: 201, text: 'Room 201', parentId: 'building-b' }, +]; + +const flatRooms = groupedRooms.filter( + (room) => !groupedRooms.some((item) => item.parentId === room.id), +); + +const groupedAppointments = [ + { + text: 'Standup', + roomId: 101, + startDate: new Date('2021-04-29T16:30:00.000Z'), + endDate: new Date('2021-04-29T18:30:00.000Z'), + }, +]; + +const groupingCases = [ + { view: 'day', orientation: 'vertical' }, + { view: 'day', orientation: 'horizontal' }, + { view: 'workWeek', orientation: 'vertical' }, + { view: 'workWeek', orientation: 'horizontal' }, + { view: 'timelineDay', orientation: 'vertical' }, + { view: 'timelineDay', orientation: 'horizontal' }, + { view: 'agenda', orientation: 'vertical' }, +]; + +([ + { name: 'flat', dataSource: flatRooms, parentIdExpr: undefined }, + { name: 'hierarchical', dataSource: groupedRooms, parentIdExpr: 'parentId' }, +] as const).forEach(({ name, dataSource, parentIdExpr }) => { + groupingCases.forEach(({ view, orientation }) => { + test(`Scheduler should pass accessibility checks with ${name} grouping on view ${view} (${orientation})`, async (t) => { + await a11yCheck(t, a11yCheckConfig, '#container'); + }).before(async () => { + await createWidget('dxScheduler', { + dataSource: groupedAppointments, + views: [{ type: view, groupOrientation: orientation }], + currentView: view, + currentDate: new Date(2021, 3, 29), + startDayHour: 9, + endDayHour: 12, + groups: ['roomId'], + resources: [{ fieldExpr: 'roomId', dataSource, parentIdExpr }], + height: 800, + }); + }); + }); +}); diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/etalons/drag-n-drop-previous-day-cell (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/etalons/drag-n-drop-previous-day-cell (fluent.blue.light).png index 3df757d96630..87df434cee2b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/etalons/drag-n-drop-previous-day-cell (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/dragAndDrop/etalons/drag-n-drop-previous-day-cell (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/etalons/scheduler-after-hiding-and-resizing (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/etalons/scheduler-after-hiding-and-resizing (fluent.blue.light).png index 2c7e0caf626e..581d2ac95ea4 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/etalons/scheduler-after-hiding-and-resizing (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/etalons/scheduler-after-hiding-and-resizing (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-header-css-vertical-grouping-long-names (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-header-css-vertical-grouping-long-names (fluent.blue.light).png index b7e44a97db81..6f7d4a0de8bf 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-header-css-vertical-grouping-long-names (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-header-css-vertical-grouping-long-names (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-allDay-0-24) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-allDay-0-24) (fluent.blue.light).png index 99693189ec59..b6463b6ec067 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-allDay-0-24) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-allDay-0-24) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-allDay-9-14) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-allDay-9-14) (fluent.blue.light).png index 659c29d0ea43..1a3f53fe0e9d 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-allDay-9-14) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-allDay-9-14) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-hidden-0-24) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-hidden-0-24) (fluent.blue.light).png index 99693189ec59..b6463b6ec067 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-hidden-0-24) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-hidden-0-24) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-hidden-9-14) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-hidden-9-14) (fluent.blue.light).png index 659c29d0ea43..1a3f53fe0e9d 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-hidden-9-14) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(month-vertical-hidden-9-14) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-allDay-0-24) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-allDay-0-24) (fluent.blue.light).png index 3e675e77c5c4..d056047261b1 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-allDay-0-24) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-allDay-0-24) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-allDay-9-14) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-allDay-9-14) (fluent.blue.light).png index b39a6bbba1e1..43f195bcd1c1 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-allDay-9-14) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-allDay-9-14) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-hidden-0-24) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-hidden-0-24) (fluent.blue.light).png index 3e675e77c5c4..d056047261b1 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-hidden-0-24) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-hidden-0-24) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-hidden-9-14) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-hidden-9-14) (fluent.blue.light).png index b39a6bbba1e1..43f195bcd1c1 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-hidden-9-14) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/group-overflow-(week-vertical-hidden-9-14) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=agenda-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=agenda-orientation=vertical) (fluent.blue.light).png new file mode 100644 index 000000000000..9db4b0d449b1 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=agenda-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=horizontal) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=horizontal) (fluent.blue.light).png new file mode 100644 index 000000000000..fc007ad01c41 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=horizontal) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=horizontal-rtl) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=horizontal-rtl) (fluent.blue.light).png new file mode 100644 index 000000000000..2811daf30f7b Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=horizontal-rtl) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=vertical) (fluent.blue.light).png new file mode 100644 index 000000000000..64e7d3e9bf94 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=vertical-rtl) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=vertical-rtl) (fluent.blue.light).png new file mode 100644 index 000000000000..bbd3db19130f Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=day-orientation=vertical-rtl) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=month-orientation=horizontal) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=month-orientation=horizontal) (fluent.blue.light).png new file mode 100644 index 000000000000..36cf7f032ef6 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=month-orientation=horizontal) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=month-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=month-orientation=vertical) (fluent.blue.light).png new file mode 100644 index 000000000000..239bd0a2de27 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=month-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineDay-orientation=horizontal) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineDay-orientation=horizontal) (fluent.blue.light).png new file mode 100644 index 000000000000..1f9888a7ebf9 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineDay-orientation=horizontal) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineDay-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineDay-orientation=vertical) (fluent.blue.light).png new file mode 100644 index 000000000000..67071c6e83f2 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineDay-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineMonth-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineMonth-orientation=vertical) (fluent.blue.light).png new file mode 100644 index 000000000000..ab0368f0f995 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineMonth-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineWeek-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineWeek-orientation=vertical) (fluent.blue.light).png new file mode 100644 index 000000000000..34a18025e730 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineWeek-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineWeek-orientation=vertical-rtl) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineWeek-orientation=vertical-rtl) (fluent.blue.light).png new file mode 100644 index 000000000000..9b5be5dd161f Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=timelineWeek-orientation=vertical-rtl) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=week-orientation=horizontal) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=week-orientation=horizontal) (fluent.blue.light).png new file mode 100644 index 000000000000..3237de0273f0 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=week-orientation=horizontal) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=week-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=week-orientation=vertical) (fluent.blue.light).png new file mode 100644 index 000000000000..5244928ead4f Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=week-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=workWeek-orientation=horizontal) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=workWeek-orientation=horizontal) (fluent.blue.light).png new file mode 100644 index 000000000000..9db0eb110355 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=workWeek-orientation=horizontal) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=workWeek-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=workWeek-orientation=vertical) (fluent.blue.light).png new file mode 100644 index 000000000000..2724d0b8ce97 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/hierarchical-grouping(view=workWeek-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_fist-app-part_T1122185 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_fist-app-part_T1122185 (fluent.blue.light).png index c272d4e50be6..e1e0d62eea5f 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_fist-app-part_T1122185 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_fist-app-part_T1122185 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_last-app-part_T1122185 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_last-app-part_T1122185 (fluent.blue.light).png index ac5da0d7bba4..671605fe45c3 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_last-app-part_T1122185 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_last-app-part_T1122185 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_middle-app-part_T1122185 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_middle-app-part_T1122185 (fluent.blue.light).png index 9addc222a620..abd815196218 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_middle-app-part_T1122185 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/etalons/month-view_vertical-grouping_middle-app-part_T1122185 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/hierarchicalGrouping.data.ts b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/hierarchicalGrouping.data.ts new file mode 100644 index 000000000000..825a61452b45 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/hierarchicalGrouping.data.ts @@ -0,0 +1,48 @@ +export const hierarchicalRooms = [ + { id: 'A', text: 'Building A', parentId: null }, + { id: 'A1', text: 'Floor 1', parentId: 'A' }, + { + id: 101, text: 'Room 101', parentId: 'A1', color: '#3f51b5', + }, + { + id: 102, text: 'Room 102', parentId: 'A1', color: '#8e24aa', + }, + { id: 'A2', text: 'Floor 2', parentId: 'A' }, + { + id: 201, text: 'Room 201', parentId: 'A2', color: '#00897b', + }, + { id: 'B', text: 'Building B', parentId: null }, + { + id: 301, text: 'Room 301', parentId: 'B', color: '#e65100', + }, + { + id: 'lobby', text: 'Lobby', parentId: null, color: '#c62828', + }, +]; + +export const hierarchicalAppointments = [ + { + text: 'Standup', roomId: 101, startDate: new Date(2021, 3, 26, 9), endDate: new Date(2021, 3, 26, 10), + }, + { + text: 'Interview', roomId: 102, startDate: new Date(2021, 3, 26, 11), endDate: new Date(2021, 3, 26, 12, 30), + }, + { + text: 'Retro', roomId: 201, startDate: new Date(2021, 3, 26, 10), endDate: new Date(2021, 3, 26, 11), + }, + { + text: 'Training', roomId: 301, startDate: new Date(2021, 3, 26, 13), endDate: new Date(2021, 3, 26, 15), + }, + { + text: 'Welcome coffee', roomId: 'lobby', startDate: new Date(2021, 3, 26, 9, 30), endDate: new Date(2021, 3, 26, 10, 30), + }, + { + text: 'Workshop', roomId: [102, 201], startDate: new Date(2021, 3, 26, 16), endDate: new Date(2021, 3, 26, 17), + }, + { + text: 'All-day event', roomId: 101, startDate: new Date(2021, 3, 26), endDate: new Date(2021, 3, 27), allDay: true, + }, + { + text: 'Multi-day', roomId: 301, startDate: new Date(2021, 3, 27, 10), endDate: new Date(2021, 3, 29, 12), + }, +]; diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/grouping/hierarchicalGrouping.ts b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/hierarchicalGrouping.ts new file mode 100644 index 000000000000..979bbf916910 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/scheduler/common/grouping/hierarchicalGrouping.ts @@ -0,0 +1,112 @@ +import { createScreenshotsComparer } from 'devextreme-screenshot-comparer'; +import { createWidget } from '../../../../helpers/createWidget'; +import url from '../../../../helpers/getPageUrl'; +import { testScreenshot } from '../../../../helpers/themeUtils'; +import { scrollToDate } from '../../helpers/utils'; +import { + hierarchicalAppointments, + hierarchicalRooms, +} from './hierarchicalGrouping.data'; + +fixture.disablePageReloads`Scheduler: Hierarchical grouping layout` + .page(url(__dirname, '../../../container.html')); + +const createScheduler = async ( + view: string, + groupOrientation: string, + rtlEnabled = false, +): Promise => { + await createWidget('dxScheduler', { + dataSource: hierarchicalAppointments, + currentDate: new Date(2021, 3, 26), + startDayHour: 9, + endDayHour: 18, + height: 780, + crossScrollingEnabled: true, + rtlEnabled, + groups: ['roomId'], + resources: [{ + fieldExpr: 'roomId', + parentIdExpr: 'parentId', + dataSource: hierarchicalRooms, + label: 'Room', + allowMultiple: true, + }], + resourceCellTemplate(itemData, _index, element) { + const { text, color, isLeaf } = itemData; + + $(element).append( + $('
') + .css({ padding: '2px 6px', textAlign: 'left', borderLeft: `4px solid ${color ?? 'transparent'}` }) + .append($('
').css({ fontWeight: isLeaf ? 400 : 700 }).text(text)), + ); + }, + views: [{ + type: view, + name: view, + groupOrientation, + }], + currentView: view, + }); +}; + +const shouldScrollToMiddleGroup = (view: string, groupOrientation: string): boolean => ( + groupOrientation === 'horizontal' && view.startsWith('timeline') +); + +const runScreenshotTest = ( + view: string, + groupOrientation: string, + rtlEnabled = false, +): void => { + test(`Hierarchical grouping layout test (view='${view}', groupOrientation=${groupOrientation}${rtlEnabled ? ', rtl=true' : ''})`, async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + + if (shouldScrollToMiddleGroup(view, groupOrientation)) { + await scrollToDate(new Date(2021, 3, 26, 12), { roomId: 201 }); + await t.wait(50); + } + + await testScreenshot( + t, + takeScreenshot, + `hierarchical-grouping(view=${view}-orientation=${groupOrientation}${rtlEnabled ? '-rtl' : ''}).png`, + ); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); + }).before(async () => createScheduler(view, groupOrientation, rtlEnabled)); +}; + +// visual: generic.light +// visual: fluent.blue.light +// visual: material.blue.light +['vertical', 'horizontal'].forEach((groupOrientation) => { + ['day', 'week', 'workWeek', 'month'].forEach((view) => { + runScreenshotTest(view, groupOrientation); + }); +}); + +// visual: generic.light +// visual: fluent.blue.light +// visual: material.blue.light +['vertical', 'horizontal'].forEach((groupOrientation) => { + ['timelineDay', 'timelineWeek', 'timelineMonth'] + .filter((view) => groupOrientation !== 'horizontal' || view === 'timelineDay') + .forEach((view) => { + runScreenshotTest(view, groupOrientation); + }); +}); + +// visual: generic.light +// visual: fluent.blue.light +// visual: material.blue.light +runScreenshotTest('agenda', 'vertical'); + +// visual: generic.light +// visual: fluent.blue.light +// visual: material.blue.light +runScreenshotTest('day', 'horizontal', true); +runScreenshotTest('day', 'vertical', true); +runScreenshotTest('timelineWeek', 'vertical', true); diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical (fluent.blue.light).png index 9bf4c833736e..84c04a6c19cb 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical-rtl (fluent.blue.light).png index e1ecc9630f1b..66541a5530d9 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=false-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical (fluent.blue.light).png index 9679a70b5f1d..dccd6a7ca896 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical-rtl (fluent.blue.light).png index 53eec7dc5e16..0ac433fcc4c3 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=day-crossScrolling=true-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=false-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=false-vertical (fluent.blue.light).png index a335aa07caa9..f5b5ef1d4d18 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=false-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=false-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=false-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=false-vertical-rtl (fluent.blue.light).png index 971174bc415e..77e3b5559dae 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=false-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=false-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=true-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=true-vertical (fluent.blue.light).png index 800cf78a0393..da348c235a30 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=true-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=true-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=true-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=true-vertical-rtl (fluent.blue.light).png index 5b6abe091f83..a243e822c71e 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=true-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=month-crossScrolling=true-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=false-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=false-vertical (fluent.blue.light).png index 7e300026142c..1fe98fe11766 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=false-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=false-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=false-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=false-vertical-rtl (fluent.blue.light).png index 406aadf88c32..bb974f79ba21 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=false-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=false-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=true-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=true-vertical (fluent.blue.light).png index 7e300026142c..1fe98fe11766 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=true-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=true-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=true-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=true-vertical-rtl (fluent.blue.light).png index 406aadf88c32..bb974f79ba21 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=true-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineDay-crossScrolling=true-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=false-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=false-vertical (fluent.blue.light).png index d31a94ed3796..35a8fbeee296 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=false-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=false-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=false-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=false-vertical-rtl (fluent.blue.light).png index dec7f5298c35..c42a5f564824 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=false-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=false-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=true-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=true-vertical (fluent.blue.light).png index d31a94ed3796..35a8fbeee296 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=true-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=true-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=true-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=true-vertical-rtl (fluent.blue.light).png index dec7f5298c35..c42a5f564824 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=true-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineMonth-crossScrolling=true-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=false-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=false-vertical (fluent.blue.light).png index 23823b4c32e8..0e6356f20a4f 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=false-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=false-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=false-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=false-vertical-rtl (fluent.blue.light).png index 0e371c009f9e..11d868a17078 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=false-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=false-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=true-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=true-vertical (fluent.blue.light).png index 23823b4c32e8..0e6356f20a4f 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=true-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=true-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=true-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=true-vertical-rtl (fluent.blue.light).png index 0e371c009f9e..11d868a17078 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=true-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=timelineWeek-crossScrolling=true-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=false-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=false-vertical (fluent.blue.light).png index 9d5eede8f620..c5f0b7de620c 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=false-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=false-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=false-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=false-vertical-rtl (fluent.blue.light).png index 791e00269114..daa72cdea31f 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=false-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=false-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=true-vertical (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=true-vertical (fluent.blue.light).png index 62196c44379d..b755bf71d4a4 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=true-vertical (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=true-vertical (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=true-vertical-rtl (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=true-vertical-rtl (fluent.blue.light).png index 8c2cb327d041..e8656b3dfc63 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=true-vertical-rtl (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/adaptive/etalons/view=week-crossScrolling=true-vertical-rtl (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-cell-sizes-in-timelineMonth (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-cell-sizes-in-timelineMonth (fluent.blue.light).png index a93b1a376bae..680ddac53871 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-cell-sizes-in-timelineMonth (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-cell-sizes-in-timelineMonth (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-cell-sizes-in-timelineWeek (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-cell-sizes-in-timelineWeek (fluent.blue.light).png index c766c5cc8422..849d13af0d9a 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-cell-sizes-in-timelineWeek (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-cell-sizes-in-timelineWeek (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-month-cross-scrolling=false (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-month-cross-scrolling=false (fluent.blue.light).png index bf1d8dcab532..4acba24f179b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-month-cross-scrolling=false (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-month-cross-scrolling=false (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-month-cross-scrolling=true (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-month-cross-scrolling=true (fluent.blue.light).png index 9bcd9c19092c..130694e3b17b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-month-cross-scrolling=true (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-month-cross-scrolling=true (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineMonth-cross-scrolling=false (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineMonth-cross-scrolling=false (fluent.blue.light).png index 30e6b0a0b236..2c2844b00124 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineMonth-cross-scrolling=false (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineMonth-cross-scrolling=false (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineMonth-cross-scrolling=true (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineMonth-cross-scrolling=true (fluent.blue.light).png index 30e6b0a0b236..2c2844b00124 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineMonth-cross-scrolling=true (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineMonth-cross-scrolling=true (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineWeek-cross-scrolling=false (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineWeek-cross-scrolling=false (fluent.blue.light).png index 7b19599f57a0..93cc17123d57 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineWeek-cross-scrolling=false (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineWeek-cross-scrolling=false (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineWeek-cross-scrolling=true (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineWeek-cross-scrolling=true (fluent.blue.light).png index 7b19599f57a0..93cc17123d57 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineWeek-cross-scrolling=true (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-timelineWeek-cross-scrolling=true (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-week-cross-scrolling=false (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-week-cross-scrolling=false (fluent.blue.light).png index e2c2ecaa1c44..b47e4c943747 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-week-cross-scrolling=false (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-week-cross-scrolling=false (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-week-cross-scrolling=true (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-week-cross-scrolling=true (fluent.blue.light).png index 6f5ee6bb128a..c3e43ba081fb 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-week-cross-scrolling=true (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/customization/etalons/custom-group-panel-in-week-cross-scrolling=true (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=day-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=day-orientation=vertical) (fluent.blue.light).png index 786d74dd09c1..06a7f38ed4c0 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=day-orientation=vertical) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=day-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=month-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=month-orientation=vertical) (fluent.blue.light).png index d04615fed4ac..4addf28f5886 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=month-orientation=vertical) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=month-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineDay-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineDay-orientation=vertical) (fluent.blue.light).png index 221b7fd3a0f8..decca4e453ef 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineDay-orientation=vertical) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineDay-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineMonth-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineMonth-orientation=vertical) (fluent.blue.light).png index c285e1cd7ddb..6de2f3cd47d7 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineMonth-orientation=vertical) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineMonth-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineWeek-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineWeek-orientation=vertical) (fluent.blue.light).png index 2cb2697b28e9..af3adb64d6ee 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineWeek-orientation=vertical) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineWeek-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineWorkWeek-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineWorkWeek-orientation=vertical) (fluent.blue.light).png index 47b473f97cd9..50c64047839e 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineWorkWeek-orientation=vertical) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=timelineWorkWeek-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=week-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=week-orientation=vertical) (fluent.blue.light).png index 38b63a0fd4e9..e0499baa89e0 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=week-orientation=vertical) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=week-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=workWeek-orientation=vertical) (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=workWeek-orientation=vertical) (fluent.blue.light).png index a1a8f1734b1a..13decd8d9e63 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=workWeek-orientation=vertical) (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/resources/groups/etalons/groups(view=workWeek-orientation=vertical) (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png index 5dcf8985f1e7..cba40c1aaa91 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png index 57d0e68e23bf..958213a8342d 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png index 92581490ff77..4058f2e1ce21 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png index b5b9cd14c64b..b02f02bdb13a 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png index 11a0605c2bfc..ffc38bed5652 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png index fffb2c3b670b..f8a468e4f5bb 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png index 8cd32c2041d8..74d7e294cfaa 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png index e4e129798f76..caeb36a7437a 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png index a6726d6e7eb2..f67dac7471b9 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png index e539bc140584..497a64244882 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_day_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png index aa7eec857954..386b3c1809a5 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png index ed5c60a8157c..f78e746a8560 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png index ac125c52364a..5f4b9e2b1ec1 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png index 9bf475a45cf6..24f0ec275b97 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png index 717f9157b611..978705701a35 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png index dcf95723a436..e3d27d1c0774 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png index 425fd227583b..0e897081b19d 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png index 9fab9d9d53ac..6e74b98c5697 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png index b8a79ba55206..0bbf97c2aea4 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png index 0908b7b948f5..0c660a0bf744 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineDay_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png index d1e781c7f5a0..62c7eaced7ea 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png index 334c79a83dc2..201d4d59af35 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png index 88488cf294a5..6538d0224f32 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png index e2199db31bfd..3a112cb90d1b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png index e9a837dea37e..4b961487ad87 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineMonth_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png index 0e18ef06cab3..1ff6f01a97fe 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png index df36f46c0d1a..9fef481e6099 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png index 4a0b8fd6780a..a21d66de3004 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png index 43e0b9ce6f6a..80ba563bd4dd 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png index e81987daea26..3b8d52e99cb8 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png index 7f9c8249196a..dc2b5efadb92 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png index 54a44a0a0522..9c1d97c1041a 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png index cbdccfefb93c..57a4053e711b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png index 7112c1cad2b4..d858e5f06b93 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png index 777b68bc75f7..9118aac2afbe 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_timelineWeek_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png index 54c44d86587b..c621efea1262 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T00-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png index 77528e459746..13a3a3d8fd5a 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T00-00-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png index 7dcc1dc0b520..0e9d0a8b9005 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T06-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png index 0440330a1abb..a0ddfef17d6b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T06-30-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png index 4d8fc505bd45..376285e3362f 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T12-00-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png index 74b5e7163cc5..6063a32a0231 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T12-00-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png index cef74682ddcf..0071f108207e 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T17-30-00_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png index 9756c55402a4..694b47a6aa5c 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T17-30-00_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png index 12bb680db982..73d47c6a9b41 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T23-59-59_g-vertical_0_24 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png index c653600cc086..3cfea25964b4 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/current-time-indicator_week_2023-12-03T23-59-59_g-vertical_6_18 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-day-crossScrolling=false-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-day-crossScrolling=false-vertical-grouping (fluent.blue.light).png index b52c8a7d3516..1db1011cd559 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-day-crossScrolling=false-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-day-crossScrolling=false-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-day-crossScrolling=true-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-day-crossScrolling=true-vertical-grouping (fluent.blue.light).png index 54174761b001..b42f1c021346 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-day-crossScrolling=true-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-day-crossScrolling=true-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=false-horizontal-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=false-horizontal-grouping (fluent.blue.light).png index 189c1867a96f..efd6b50c3bf6 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=false-horizontal-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=false-horizontal-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=false-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=false-vertical-grouping (fluent.blue.light).png index 189c1867a96f..efd6b50c3bf6 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=false-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=false-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=true-horizontal-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=true-horizontal-grouping (fluent.blue.light).png index 189c1867a96f..efd6b50c3bf6 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=true-horizontal-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=true-horizontal-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=true-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=true-vertical-grouping (fluent.blue.light).png index 189c1867a96f..efd6b50c3bf6 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=true-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineDay-crossScrolling=true-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineMonth-crossScrolling=false-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineMonth-crossScrolling=false-vertical-grouping (fluent.blue.light).png index 2bbf5d162759..1a160d2c2237 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineMonth-crossScrolling=false-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineMonth-crossScrolling=false-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineMonth-crossScrolling=true-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineMonth-crossScrolling=true-vertical-grouping (fluent.blue.light).png index 2bbf5d162759..1a160d2c2237 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineMonth-crossScrolling=true-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineMonth-crossScrolling=true-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineWeek-crossScrolling=false-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineWeek-crossScrolling=false-vertical-grouping (fluent.blue.light).png index 2d7fe75817c4..f5b123f0acdb 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineWeek-crossScrolling=false-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineWeek-crossScrolling=false-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineWeek-crossScrolling=true-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineWeek-crossScrolling=true-vertical-grouping (fluent.blue.light).png index 2d7fe75817c4..f5b123f0acdb 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineWeek-crossScrolling=true-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-timelineWeek-crossScrolling=true-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-week-crossScrolling=false-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-week-crossScrolling=false-vertical-grouping (fluent.blue.light).png index 3cdd0b058a4f..1cb016c71939 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-week-crossScrolling=false-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-week-crossScrolling=false-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-week-crossScrolling=true-vertical-grouping (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-week-crossScrolling=true-vertical-grouping (fluent.blue.light).png index 114e355c9b20..a9582d659998 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-week-crossScrolling=true-vertical-grouping (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/timeIndication/etalons/shader-in-week-crossScrolling=true-vertical-grouping (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=false-interval=1 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=false-interval=1 (fluent.blue.light).png index 4072554376b5..d6866f43b44e 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=false-interval=1 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=false-interval=1 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=false-interval=2 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=false-interval=2 (fluent.blue.light).png index 4dae4814c633..32814601463b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=false-interval=2 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=false-interval=2 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=true-interval=1 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=true-interval=1 (fluent.blue.light).png index 506cbc4fe40c..2dba3aedfbf5 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=true-interval=1 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=true-interval=1 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=true-interval=2 (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=true-interval=2 (fluent.blue.light).png index 689181ec1f72..ecb1fba7bc2e 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=true-interval=2 (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/layout/views/day/etalons/day-orientation=vertical-allDay=true-interval=2 (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-month-vertical-start (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-month-vertical-start (fluent.blue.light).png index ee732181b7ad..75e9b51afdfe 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-month-vertical-start (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-month-vertical-start (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-week-vertical-start (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-week-vertical-start (fluent.blue.light).png index 83434b2758b5..eaa86ed005f9 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-week-vertical-start (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-week-vertical-start (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-workWeek-vertical-start (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-workWeek-vertical-start (fluent.blue.light).png index 812c2cfaa5f0..dc8ce90ac828 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-workWeek-vertical-start (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/scheduler/common/virtualScrolling/etalons/virtual-scrolling-many-cells-workWeek-vertical-start (fluent.blue.light).png differ diff --git a/packages/devextreme-angular/src/ui/scheduler/index.ts b/packages/devextreme-angular/src/ui/scheduler/index.ts index 57f11fdf79e9..08e719a41fa4 100644 --- a/packages/devextreme-angular/src/ui/scheduler/index.ts +++ b/packages/devextreme-angular/src/ui/scheduler/index.ts @@ -685,10 +685,10 @@ export class DxSchedulerComponent extends DxComponent implements OnDestroy, OnCh */ @Input() - get resources(): { allowMultiple?: boolean, colorExpr?: string, dataSource?: Array | DataSource | DataSourceOptions | null | Store | string, displayExpr?: ((resource: any) => string) | string, fieldExpr?: string, icon?: string, label?: string, useColorAsDefault?: boolean, valueExpr?: Function | string }[] { + get resources(): { allowMultiple?: boolean, colorExpr?: string, dataSource?: Array | DataSource | DataSourceOptions | null | Store | string, displayExpr?: ((resource: any) => string) | string, fieldExpr?: string, icon?: string, label?: string, parentIdExpr?: string, useColorAsDefault?: boolean, valueExpr?: Function | string }[] { return this._getOption('resources'); } - set resources(value: { allowMultiple?: boolean, colorExpr?: string, dataSource?: Array | DataSource | DataSourceOptions | null | Store | string, displayExpr?: ((resource: any) => string) | string, fieldExpr?: string, icon?: string, label?: string, useColorAsDefault?: boolean, valueExpr?: Function | string }[]) { + set resources(value: { allowMultiple?: boolean, colorExpr?: string, dataSource?: Array | DataSource | DataSourceOptions | null | Store | string, displayExpr?: ((resource: any) => string) | string, fieldExpr?: string, icon?: string, label?: string, parentIdExpr?: string, useColorAsDefault?: boolean, valueExpr?: Function | string }[]) { this._setOption('resources', value); } @@ -1387,7 +1387,7 @@ export class DxSchedulerComponent extends DxComponent implements OnDestroy, OnCh * This member supports the internal infrastructure and is not intended to be used directly from your code. */ - @Output() resourcesChange: EventEmitter<{ allowMultiple?: boolean, colorExpr?: string, dataSource?: Array | DataSource | DataSourceOptions | null | Store | string, displayExpr?: ((resource: any) => string) | string, fieldExpr?: string, icon?: string, label?: string, useColorAsDefault?: boolean, valueExpr?: Function | string }[]>; + @Output() resourcesChange: EventEmitter<{ allowMultiple?: boolean, colorExpr?: string, dataSource?: Array | DataSource | DataSourceOptions | null | Store | string, displayExpr?: ((resource: any) => string) | string, fieldExpr?: string, icon?: string, label?: string, parentIdExpr?: string, useColorAsDefault?: boolean, valueExpr?: Function | string }[]>; /** diff --git a/packages/devextreme-angular/src/ui/scheduler/nested/resource-dxi.ts b/packages/devextreme-angular/src/ui/scheduler/nested/resource-dxi.ts index f07d22779a89..eee08211583f 100644 --- a/packages/devextreme-angular/src/ui/scheduler/nested/resource-dxi.ts +++ b/packages/devextreme-angular/src/ui/scheduler/nested/resource-dxi.ts @@ -93,6 +93,14 @@ export class DxiSchedulerResourceComponent extends CollectionNestedOption { this._setOption('label', value); } + @Input() + get parentIdExpr(): string { + return this._getOption('parentIdExpr'); + } + set parentIdExpr(value: string) { + this._setOption('parentIdExpr', value); + } + @Input() get useColorAsDefault(): boolean { return this._getOption('useColorAsDefault'); diff --git a/packages/devextreme-metadata/make-angular-metadata.ts b/packages/devextreme-metadata/make-angular-metadata.ts index 8ddde37f2223..50372fcae427 100644 --- a/packages/devextreme-metadata/make-angular-metadata.ts +++ b/packages/devextreme-metadata/make-angular-metadata.ts @@ -68,6 +68,7 @@ Ng.makeMetadata({ removeMembers(/\/scheduler:dxSchedulerOptions\.editing\.form/), removeMembers(/\/scheduler:dxSchedulerOptions\.editing\.popup/), removeMembers(/\/scheduler:dxSchedulerOptions\.resources\.icon/), + removeMembers(/\/scheduler:dxSchedulerOptions\.resources\.parentIdExpr/), removeMembers(/\/scheduler:.*\.snapToCellsMode/), removeMembers(/\/scheduler:.*\.hiddenWeekDays/), removeMembers(/\/stepper:/), diff --git a/packages/devextreme-react/src/scheduler.ts b/packages/devextreme-react/src/scheduler.ts index 28bed71c907b..37a2c0b97db1 100644 --- a/packages/devextreme-react/src/scheduler.ts +++ b/packages/devextreme-react/src/scheduler.ts @@ -977,6 +977,7 @@ type IResourceProps = React.PropsWithChildren<{ fieldExpr?: string; icon?: string; label?: string; + parentIdExpr?: string; useColorAsDefault?: boolean; valueExpr?: (() => void) | string; }> 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 af44f6164aae..8d79e3155667 100644 --- a/packages/devextreme-scss/scss/widgets/base/scheduler/views/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/views/_index.scss @@ -310,11 +310,6 @@ $scheduler-month-date-text-padding: 6px; flex: 0 0 auto; min-width: 0; - &:last-child .dx-scheduler-group-header { - border-right: $scheduler-base-border; - border-right-color: $scheduler-base-border-color; - } - .dx-scheduler-group-header { @include flex-container(row, nowrap); @@ -332,6 +327,80 @@ $scheduler-month-date-text-padding: 6px; border-top-color: $scheduler-base-border-color; } } + + &:not(.dx-scheduler-group-flex-container-hierarchical) .dx-scheduler-group-row > .dx-scheduler-group-header { + border-right: $scheduler-base-border; + border-right-color: $scheduler-base-border-color; + } + + > .dx-scheduler-group-row:first-child > .dx-scheduler-group-header:first-child { + border-top-color: transparent; + } + + .dx-scheduler-group-row:not(:first-child) .dx-scheduler-group-row:first-child > .dx-scheduler-group-header:first-child { + border-top-color: $scheduler-base-border-color; + } +} + +.dx-scheduler-work-space-vertical-group-table, +.dx-scheduler-group-table { + > .dx-scheduler-group-flex-container:not(.dx-scheduler-group-flex-container-hierarchical) > .dx-scheduler-group-row > .dx-scheduler-group-header:first-child { + border-top-color: transparent; + } +} + +.dx-scheduler-group-flex-container-hierarchical { + flex-direction: column; + + .dx-scheduler-group-row > .dx-scheduler-group-header:not(.dx-scheduler-group-header-leaf) { + border-right: $scheduler-base-border; + border-right-color: $scheduler-base-border-color; + } + + .dx-scheduler-group-row { + @include flex-container(row, nowrap); + + flex: 1 1 0; + width: 100%; + min-width: 0; + + .dx-scheduler-group-header { + @include flex-container(row, nowrap); + + flex: 0 0 auto; + padding: 0 5px; + height: 100%; + width: $scheduler-left-column-width; + justify-content: flex-start; + align-items: flex-start; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + border: none; + border-top: $scheduler-base-border; + border-top-color: $scheduler-base-border-color; + + &.dx-scheduler-group-header-leaf { + flex: 1 1 auto; + width: auto; + border-right: $scheduler-base-border; + border-right-color: $scheduler-base-border-color; + } + } + + .dx-scheduler-group-flex-container { + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + width: auto; + } + } + + .dx-scheduler-group-row > .dx-scheduler-group-header.dx-scheduler-group-header-leaf:only-child { + flex: 0 0 auto; + width: 100%; + min-width: $scheduler-left-column-width; + } } .dx-scheduler-header-scrollable { @@ -798,7 +867,7 @@ $scheduler-month-date-text-padding: 6px; .dx-scheduler-work-space-vertical-grouped, .dx-scheduler-sidebar-scrollable { - .dx-scheduler-group-row:last-child .dx-scheduler-group-header { + .dx-scheduler-group-row > .dx-scheduler-group-header { border-right: none; border-left: $scheduler-base-border; border-left-color: $scheduler-base-border-color; diff --git a/packages/devextreme-scss/scss/widgets/base/scheduler/views/timelines/_index.scss b/packages/devextreme-scss/scss/widgets/base/scheduler/views/timelines/_index.scss index d5226a597875..b8469f2b1e83 100644 --- a/packages/devextreme-scss/scss/widgets/base/scheduler/views/timelines/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/views/timelines/_index.scss @@ -195,11 +195,16 @@ $scheduler-timeline-min-height: 100px; } .dx-scheduler-group-row .dx-scheduler-group-header { - border: none; + border-bottom: none; border-top: $scheduler-base-border; border-top-color: $scheduler-base-border-color; } + .dx-scheduler-group-row th.dx-scheduler-group-header { + border-right: none; + border-left: none; + } + &.dx-scheduler-work-space-group-by-date { .dx-scheduler-group-row .dx-scheduler-group-header { border-right: $scheduler-base-border; diff --git a/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss index a40c6ea98837..411da87e0147 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/scheduler/_index.scss @@ -654,6 +654,11 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; .dx-scheduler-group-header { border-bottom: none; } + + .dx-scheduler-header-panel .dx-scheduler-group-row:has(+ .dx-scheduler-group-row) .dx-scheduler-group-header { + border-bottom: baseScheduler.$scheduler-base-border; + border-bottom-color: $scheduler-base-border-color; + } } &.dx-scheduler-agenda { @@ -771,7 +776,7 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; border-right: 1px solid; border-right-color: $scheduler-group-separator-border-color; - &:last-child { + &:last-child:not(:where(.dx-scheduler-group-header-inner-column)) { border-right: none; } @@ -781,7 +786,7 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; border-right: baseScheduler.$scheduler-base-border; border-right-color: $scheduler-base-border-color; - &:last-child { + &:last-child:not(:where(.dx-scheduler-group-header-inner-column)) { border-left: none; } } @@ -1035,7 +1040,11 @@ $fluent-scheduler-agenda-time-panel-cell-padding: 8px; } .dx-scheduler-timeline .dx-scheduler-group-flex-container .dx-scheduler-group-header { - width: 100px; + width: $fluent-scheduler-group-header-table-cell-width; +} + +.dx-scheduler-timeline .dx-scheduler-group-flex-container-hierarchical .dx-scheduler-group-row > .dx-scheduler-group-header.dx-scheduler-group-header-leaf:only-child { + min-width: $fluent-scheduler-group-header-table-cell-width; } .dx-scheduler-header-panel-empty-cell { diff --git a/packages/devextreme-scss/scss/widgets/generic/scheduler/_index.scss b/packages/devextreme-scss/scss/widgets/generic/scheduler/_index.scss index 2abce2057d9c..222707c3bc74 100644 --- a/packages/devextreme-scss/scss/widgets/generic/scheduler/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/scheduler/_index.scss @@ -164,15 +164,16 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c background-color: $scheduler-header-bg; } -.dx-scheduler-header-panel { // stylelint-disable-line no-duplicate-selectors +.dx-scheduler-group-table, +.dx-scheduler-header-panel { .dx-scheduler-group-row { - &:not(:first-child) { + .dx-scheduler-group-header { border-bottom: baseScheduler.$scheduler-base-border; border-bottom-color: $scheduler-base-border-color; + } - .dx-scheduler-group-header { - color: $scheduler-panel-text-color; - } + &:not(:first-child) .dx-scheduler-group-header { + color: $scheduler-panel-text-color; } } } @@ -198,6 +199,17 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c border-top-color: $scheduler-base-border-color; } + &.dx-scheduler-work-space-all-day:not(.dx-scheduler-work-space-week):not(.dx-scheduler-work-space-work-week) { + .dx-scheduler-group-header { + border-bottom: none; + } + + .dx-scheduler-header-panel .dx-scheduler-group-row:has(+ .dx-scheduler-group-row) .dx-scheduler-group-header { + border-bottom: baseScheduler.$scheduler-base-border; + border-bottom-color: $scheduler-base-border-color; + } + } + &.dx-scheduler-agenda { .dx-scheduler-date-table-cell { border: none; @@ -324,7 +336,7 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c border-right: 1px solid; border-right-color: $scheduler-group-separator-border-color; - &:last-child { + &:last-child:not(:where(.dx-scheduler-group-header-inner-column)) { border-right: none; } @@ -334,7 +346,7 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c border-right: baseScheduler.$scheduler-base-border; border-right-color: $scheduler-base-border-color; - &:last-child { + &:last-child:not(:where(.dx-scheduler-group-header-inner-column)) { border-left: none; } } @@ -365,7 +377,7 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c border-right: 1px solid; border-right-color: $scheduler-group-separator-border-color; - &:last-child { + &:last-child:not(:where(.dx-scheduler-group-header-inner-column)) { border-right: none; } @@ -375,7 +387,7 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c border-right: baseScheduler.$scheduler-base-border; border-right-color: $scheduler-base-border-color; - &:last-child { + &:last-child:not(:where(.dx-scheduler-group-header-inner-column)) { border-left: none; } } @@ -534,6 +546,22 @@ $generic-scheduler-agenda-group-header-padding: $generic-scheduler-agenda-time-c box-shadow: inset 0 -1px 0 0 $scheduler-accent-border-color; } } + + &.dx-scheduler-work-space-grouped { + .dx-scheduler-group-header { + border-bottom: none; + } + + .dx-scheduler-date-table-row.dx-scheduler-date-table-last-row, + .dx-scheduler-time-panel-row.dx-scheduler-date-table-last-row, + .dx-scheduler-group-table .dx-scheduler-group-row { + border-bottom: none; + + &:not(:last-child) { + box-shadow: inset 0 -1px 0 0 $scheduler-group-separator-border-color; + } + } + } } .dx-scheduler-agenda-nodata { diff --git a/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss b/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss index 8f45e24750f9..b4bac4364e4d 100644 --- a/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss @@ -594,6 +594,11 @@ $material-scheduler-agenda-time-panel-cell-padding: 8px; .dx-scheduler-group-header { border-bottom: none; } + + .dx-scheduler-header-panel .dx-scheduler-group-row:has(+ .dx-scheduler-group-row) .dx-scheduler-group-header { + border-bottom: baseScheduler.$scheduler-base-border; + border-bottom-color: $scheduler-base-border-color; + } } &.dx-scheduler-agenda { @@ -744,7 +749,7 @@ $material-scheduler-agenda-time-panel-cell-padding: 8px; border-right: 1px solid; border-right-color: $scheduler-group-separator-border-color; - &:last-child { + &:last-child:not(:where(.dx-scheduler-group-header-inner-column)) { border-right: none; } @@ -754,7 +759,7 @@ $material-scheduler-agenda-time-panel-cell-padding: 8px; border-right: baseScheduler.$scheduler-base-border; border-right-color: $scheduler-base-border-color; - &:last-child { + &:last-child:not(:where(.dx-scheduler-group-header-inner-column)) { border-left: none; } } @@ -970,7 +975,11 @@ $material-scheduler-agenda-time-panel-cell-padding: 8px; } .dx-scheduler-timeline .dx-scheduler-group-flex-container .dx-scheduler-group-header { - width: 100px; + width: $material-scheduler-group-header-table-cell-width; +} + +.dx-scheduler-timeline .dx-scheduler-group-flex-container-hierarchical .dx-scheduler-group-row > .dx-scheduler-group-header.dx-scheduler-group-header-leaf:only-child { + min-width: $material-scheduler-group-header-table-cell-width; } .dx-scheduler-header-panel-empty-cell { diff --git a/packages/devextreme-vue/src/scheduler.ts b/packages/devextreme-vue/src/scheduler.ts index ead3818f5f1b..44159f2066e5 100644 --- a/packages/devextreme-vue/src/scheduler.ts +++ b/packages/devextreme-vue/src/scheduler.ts @@ -1295,6 +1295,7 @@ const DxResourceConfig = { "update:fieldExpr": null, "update:icon": null, "update:label": null, + "update:parentIdExpr": null, "update:useColorAsDefault": null, "update:valueExpr": null, }, @@ -1306,6 +1307,7 @@ const DxResourceConfig = { fieldExpr: String, icon: String, label: String, + parentIdExpr: String, useColorAsDefault: Boolean, valueExpr: [Function, String] as PropType<((() => void)) | string> } diff --git a/packages/devextreme/js/__internal/scheduler/__mock__/resource_manager.mock.ts b/packages/devextreme/js/__internal/scheduler/__mock__/resource_manager.mock.ts index efa7be516244..e3f87d23b054 100644 --- a/packages/devextreme/js/__internal/scheduler/__mock__/resource_manager.mock.ts +++ b/packages/devextreme/js/__internal/scheduler/__mock__/resource_manager.mock.ts @@ -42,6 +42,21 @@ export const complexIdResourceMock = [{ ], }]; +export const hierarchicalRoomsMock = [ + { id: 'board', text: 'Board rooms', parentId: null }, + { id: 'open', text: 'Open spaces', parentId: null }, + { id: 11, text: 'Room 11', parentId: 'board' }, + { id: 12, text: 'Room 12', parentId: 'board' }, + { id: 21, text: 'Room 21', parentId: 'open' }, + { id: 'solo', text: 'Solo room', parentId: null }, +]; + +export const hierarchicalRoomsConfigMock = { + fieldExpr: 'roomId', + dataSource: hierarchicalRoomsMock, + parentIdExpr: 'parentId', +}; + export const resourceIndexesMock = Object.keys(resourceItemsByIdMock); export const resourceConfigMock = [{ diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/create_appointment_popup.ts b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/create_appointment_popup.ts index 6f5313ff9856..2bbb20e8c231 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/create_appointment_popup.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/create_appointment_popup.ts @@ -66,6 +66,7 @@ interface CreateAppointmentPopupOptions { startDayHour?: number; timeZone?: string; resources?: ResourceConfig[]; + preloadResources?: boolean; onAppointmentFormOpening?: (...args: unknown[]) => void; onSave?: jest.Mock<(appointment: Record) => PromiseLike>; title?: string; @@ -112,7 +113,7 @@ export const createAppointmentPopup = async ( const timeZoneCalculator = createTimeZoneCalculator(options.timeZone ?? NO_TIMEZONE); const editing = { ...DEFAULT_EDITING, ...options.editing }; - if (options.resources?.length) { + if (options.resources?.length && options.preloadResources !== false) { await Promise.all( resourceManager.resources.map((r) => r.load()), ); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/hierarchical_grouping.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/hierarchical_grouping.test.ts new file mode 100644 index 000000000000..201f5dd55fa5 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/hierarchical_grouping.test.ts @@ -0,0 +1,250 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import fx from '@js/common/core/animation/fx'; +import $ from '@js/core/renderer'; +import type { Appointment, Properties } from '@js/ui/scheduler'; +import { hierarchicalRoomsConfigMock } from '@ts/scheduler/__mock__/resource_manager.mock'; +import type { ResourceCellTemplateData } from '@ts/scheduler/r1/components/types'; +import type Scheduler from '@ts/scheduler/scheduler'; +import type ViewDataProvider from '@ts/scheduler/workspaces/view_model/view_data_provider'; + +import { createScheduler } from './__mock__/create_scheduler'; +import { DEFAULT_CELL_HEIGHT, setupSchedulerTestEnvironment } from './__mock__/mock_scheduler'; +import type { SchedulerModel } from './__mock__/model/scheduler'; + +// A 3 hour day with a 60 min cellDuration makes every leaf group 3 rows tall +const ROWS_IN_GROUP = 3; +const GROUP_HEIGHT = ROWS_IN_GROUP * DEFAULT_CELL_HEIGHT; + +const createAppointment = (text: string, roomId: unknown): Appointment => ({ + text, + roomId, + startDate: new Date(2015, 1, 9, 9), + endDate: new Date(2015, 1, 9, 10), +} as Appointment); + +const getViewDataProvider = (scheduler: Scheduler): ViewDataProvider => ( + (scheduler as unknown as { _workSpace: { viewDataProvider: ViewDataProvider } })._workSpace + .viewDataProvider +); + +const getGroupCount = (scheduler: Scheduler): number => ( + (scheduler as unknown as { resourceManager: { groupCount: () => number } }) + .resourceManager.groupCount() +); + +const createHierarchicalScheduler = ( + dataSource: Appointment[] = [], +): ReturnType => createScheduler({ + currentView: 'day', + views: [{ type: 'day', groupOrientation: 'vertical' }], + currentDate: new Date(2015, 1, 9), + startDayHour: 9, + endDayHour: 12, + cellDuration: 60, + showAllDayPanel: false, + groups: ['roomId'], + resources: [{ ...hierarchicalRoomsConfigMock }] as unknown as Properties['resources'], + dataSource, + height: 1200, +}); + +const getAppointmentTops = (POM: SchedulerModel): Record => POM + .getAppointments() + .reduce>((result, appointment) => { + result[appointment.getText()] = appointment.getGeometry().top; + return result; + }, {}); + +describe('Hierarchical grouping', () => { + beforeEach(() => { + fx.off = true; + setupSchedulerTestEnvironment(); + }); + + afterEach(() => { + const $scheduler = $('.dx-scheduler'); + // @ts-expect-error + $scheduler.dxScheduler('dispose'); + document.body.innerHTML = ''; + fx.off = false; + }); + + it('should create one contiguous group band per hierarchy leaf, parents excluded', async () => { + const { scheduler } = await createHierarchicalScheduler(); + const viewDataProvider = getViewDataProvider(scheduler); + + expect(getGroupCount(scheduler)).toBe(4); + expect([0, 1, 2, 3].map((groupIndex) => viewDataProvider.getCellsGroup(groupIndex))).toEqual([ + { roomId: 11 }, + { roomId: 12 }, + { roomId: 21 }, + { roomId: 'solo' }, + ]); + + expect([0, 1, 2, 3].map( + (groupIndex) => viewDataProvider.getRowCountInGroup(groupIndex), + )).toEqual([ROWS_IN_GROUP, ROWS_IN_GROUP, ROWS_IN_GROUP, ROWS_IN_GROUP]); + + expect([0, 1, 2, 3].map( + (groupIndex) => viewDataProvider.getLastGroupCellPosition(groupIndex), + )).toEqual([ + { rowIndex: 2, columnIndex: 0 }, + { rowIndex: 5, columnIndex: 0 }, + { rowIndex: 8, columnIndex: 0 }, + { rowIndex: 11, columnIndex: 0 }, + ]); + + expect(document.querySelectorAll('.dx-scheduler-date-table-row')).toHaveLength(12); + }); + + it('should render an appointment in the band of its own leaf', async () => { + const { POM } = await createHierarchicalScheduler([ + createAppointment('Room 11', 11), + createAppointment('Room 12', 12), + createAppointment('Room 21', 21), + createAppointment('Solo room', 'solo'), + ]); + + expect(getAppointmentTops(POM)).toEqual({ + 'Room 11': 0, + 'Room 12': GROUP_HEIGHT, + 'Room 21': GROUP_HEIGHT * 2, + 'Solo room': GROUP_HEIGHT * 3, + }); + }); + + it('should not render an appointment bound to a parent id', async () => { + const { POM } = await createHierarchicalScheduler([ + createAppointment('Board rooms', 'board'), + createAppointment('Room 21', 21), + ]); + + expect(POM.getAppointments().map((appointment) => appointment.getText())) + .toEqual(['Room 21']); + }); + + it('should render an allowMultiple appointment in each of its leaf bands', async () => { + const { POM } = await createScheduler({ + currentView: 'day', + views: [{ type: 'day', groupOrientation: 'vertical' }], + currentDate: new Date(2015, 1, 9), + startDayHour: 9, + endDayHour: 12, + cellDuration: 60, + showAllDayPanel: false, + groups: ['roomId'], + resources: [{ + ...hierarchicalRoomsConfigMock, + allowMultiple: true, + }] as unknown as Properties['resources'], + dataSource: [createAppointment('Shared', [12, 'solo'])], + height: 1200, + }); + + expect(POM.getAppointments().map((appointment) => appointment.getGeometry().top)) + .toEqual([GROUP_HEIGHT, GROUP_HEIGHT * 3]); + }); + + describe('resourceCellTemplate', () => { + const createSchedulerWithTemplate = async ( + groupOrientation: 'vertical' | 'horizontal', + ): Promise => { + const templateData: ResourceCellTemplateData[] = []; + + await createScheduler({ + currentView: 'day', + views: [{ type: 'day', groupOrientation }], + currentDate: new Date(2015, 1, 9), + startDayHour: 9, + endDayHour: 12, + cellDuration: 60, + showAllDayPanel: false, + groups: ['roomId'], + resources: [{ ...hierarchicalRoomsConfigMock }] as unknown as Properties['resources'], + height: 1200, + resourceCellTemplate: (itemData: ResourceCellTemplateData, _: number, element: Element) => { + templateData.push(itemData); + element.textContent = `custom ${itemData.text ?? ''}`; + }, + }); + + return templateData; + }; + + const describeCell = ( + data: ResourceCellTemplateData, + ): Record => ({ + text: data.text, + level: data.level, + isLeaf: data.isLeaf, + resourceIndex: data.resourceIndex, + path: data.path.map((item) => item.text), + }); + + const boardRooms = { + text: 'Board rooms', level: 0, isLeaf: false, resourceIndex: 'roomId', path: ['Board rooms'], + }; + const room11 = { + text: 'Room 11', level: 1, isLeaf: true, resourceIndex: 'roomId', path: ['Board rooms', 'Room 11'], + }; + const room12 = { + text: 'Room 12', level: 1, isLeaf: true, resourceIndex: 'roomId', path: ['Board rooms', 'Room 12'], + }; + const openSpaces = { + text: 'Open spaces', level: 0, isLeaf: false, resourceIndex: 'roomId', path: ['Open spaces'], + }; + const room21 = { + text: 'Room 21', level: 1, isLeaf: true, resourceIndex: 'roomId', path: ['Open spaces', 'Room 21'], + }; + const soloRoom = { + text: 'Solo room', level: 0, isLeaf: true, resourceIndex: 'roomId', path: ['Solo room'], + }; + + it('should pass hierarchy-aware data to every header cell template for vertical grouping', async () => { + const templateData = await createSchedulerWithTemplate('vertical'); + + expect(templateData.map(describeCell)).toEqual([ + boardRooms, room11, room12, openSpaces, room21, soloRoom, + ]); + }); + + it('should pass hierarchy-aware data to every header cell template for horizontal grouping', async () => { + const templateData = await createSchedulerWithTemplate('horizontal'); + + expect(templateData.map(describeCell)).toEqual([ + boardRooms, openSpaces, soloRoom, room11, room12, room21, + ]); + }); + + it('should pass the resource data of the cell and of every path item', async () => { + const templateData = await createSchedulerWithTemplate('vertical'); + const room11Data = templateData + .find((data) => data.text === 'Room 11') as ResourceCellTemplateData; + + expect(room11Data.data).toEqual({ id: 11, text: 'Room 11', parentId: 'board' }); + expect(room11Data.id).toBe(11); + expect(room11Data.path.map((item) => item.data)).toEqual([ + { id: 'board', text: 'Board rooms', parentId: null }, + { id: 11, text: 'Room 11', parentId: 'board' }, + ]); + }); + + it('should render custom template content in parent and leaf header cells', async () => { + await createSchedulerWithTemplate('vertical'); + + const headerTexts = [...document.querySelectorAll('.dx-scheduler-group-header')] + .map((cell) => cell.textContent); + + expect(headerTexts).toEqual([ + 'custom Board rooms', + 'custom Room 11', + 'custom Room 12', + 'custom Open spaces', + 'custom Room 21', + 'custom Solo room', + ]); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointment_popup/appointment_popup.test.ts b/packages/devextreme/js/__internal/scheduler/appointment_popup/appointment_popup.test.ts index ef7d5aaa7747..d5acda52128b 100644 --- a/packages/devextreme/js/__internal/scheduler/appointment_popup/appointment_popup.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointment_popup/appointment_popup.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; import eventsEngine from '@js/common/core/events/core/events_engine'; +import type DataSource from '@js/data/data_source'; import { loadMessages, locale } from '@js/localization'; import type { GroupItem } from '@js/ui/form'; import { fireEvent } from '@testing-library/dom'; @@ -1339,6 +1340,88 @@ describe('Isolated AppointmentPopup environment', () => { expect(resourceEditor.NAME).toBe('dxSelectBox'); expect(resourceEditor.option('value')).toEqual(2); }); + + describe('Hierarchical resources', () => { + const rooms = [ + { id: 'board', text: 'Board rooms' }, + { id: 11, text: 'Room 11', parentId: 'board' }, + { id: 12, text: 'Room 12', parentId: 'board' }, + { id: 21, text: 'Room 21' }, + ]; + + const appointmentData = { + text: 'Resource test app', + startDate: new Date(2017, 4, 9, 9, 30), + endDate: new Date(2017, 4, 9, 11), + roomId: 11, + }; + + const hierarchicalResources = [{ + fieldExpr: 'roomId', + parentIdExpr: 'parentId', + dataSource: rooms, + }]; + + it('should offer hierarchy leaves in the editor', async () => { + const { POM } = await createAppointmentPopup({ + appointmentData, + resources: hierarchicalResources, + }); + + const resourceEditor = POM.dxForm.getEditor('roomId'); + expect(resourceEditor?.option('dataSource')).toEqual([rooms[1], rooms[2], rooms[3]]); + expect(resourceEditor?.option('value')).toBe(11); + }); + + it('should keep the resource displayExpr and valueExpr', async () => { + const resources = [{ + fieldExpr: 'roomId', + parentIdExpr: 'parent', + valueExpr: 'key', + displayExpr: 'name', + dataSource: [ + { key: 'board', name: 'Board rooms' }, + { key: 'room-11', name: 'Room 11', parent: 'board' }, + ], + }]; + + const { POM } = await createAppointmentPopup({ + appointmentData: { ...appointmentData, roomId: 'room-11' }, + resources, + }); + + const resourceEditor = POM.dxForm.getEditor('roomId'); + expect(resourceEditor?.option('displayExpr')).toBe('name'); + expect(resourceEditor?.option('valueExpr')).toBe('key'); + expect(resourceEditor?.option('dataSource')).toEqual([ + { key: 'room-11', name: 'Room 11', parent: 'board' }, + ]); + expect(resourceEditor?.option('value')).toBe('room-11'); + }); + + it('should offer hierarchy leaves for a resource that was not loaded yet', async () => { + const { POM } = await createAppointmentPopup({ + preloadResources: false, + appointmentData, + resources: hierarchicalResources, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editorDataSource = (POM.dxForm.getEditor('roomId') as any).getDataSource(); + + await editorDataSource.load(); + + expect(editorDataSource.items()).toEqual([rooms[1], rooms[2], rooms[3]]); + }); + + it('should keep the lazy dataSource for a resource without parentIdExpr', async () => { + const resources = [{ fieldExpr: 'roomId', dataSource: rooms }]; + + const { POM } = await createAppointmentPopup({ appointmentData, resources }); + + const editorDataSource = POM.dxForm.getEditor('roomId')?.option('dataSource'); + expect((editorDataSource as DataSource).items()).toEqual(rooms); + }); + }); }); describe('Icons', () => { diff --git a/packages/devextreme/js/__internal/scheduler/appointment_popup/form.ts b/packages/devextreme/js/__internal/scheduler/appointment_popup/form.ts index 2f2d232b519b..089d8b543d90 100644 --- a/packages/devextreme/js/__internal/scheduler/appointment_popup/form.ts +++ b/packages/devextreme/js/__internal/scheduler/appointment_popup/form.ts @@ -6,6 +6,7 @@ import '@js/ui/select_box'; import type { DayOfWeek, TextEditorButton } from '@js/common'; import messageLocalization from '@js/common/core/localization/message'; +import type { DataSourceOptions } from '@js/common/data'; import { DataSource } from '@js/common/data'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; @@ -31,6 +32,7 @@ import type { TimeZoneCalculator } from '../r1/timezone_calculator/calculator'; import type { CreateComponentFn, SafeAppointment } from '../types'; import type { AppointmentDataAccessor } from '../utils/data_accessor/appointment_data_accessor'; import type { ResourceLoader } from '../utils/loader/resource_loader'; +import type { RawResourceData } from '../utils/loader/types'; import { DEFAULT_ICONS_SHOW_MODE } from '../utils/options/constants'; import { getAppointmentGroupIndex, getRawAppointmentGroupValues, getSafeGroupValues } from '../utils/resource_manager/appointment_groups_utils'; import type { ResourceManager } from '../utils/resource_manager/resource_manager'; @@ -97,6 +99,23 @@ const CLASSES = { recurrenceHidden: 'dx-scheduler-form-recurrence-group-hidden', }; +const getResourceEditorDataSource = ( + resourceLoader: ResourceLoader, +): ResourceLoader['dataSource'] | RawResourceData[] | DataSourceOptions => { + if (!resourceLoader.hasHierarchy) { + return resourceLoader.dataSource; + } + + return resourceLoader.isLoaded() + ? resourceLoader.leafData + : { + store: resourceLoader.dataSource?.store(), + postProcess: (data: RawResourceData[]): RawResourceData[] => resourceLoader + .collectLeafData(data), + paginate: false, + }; +}; + const createTimeZoneDataSource = (): DataSource => new DataSource({ store: timeZoneUtils.getTimeZonesCache(), paginate: true, @@ -750,11 +769,10 @@ export class AppointmentForm { } private createResourcesGroup(): GroupItem { - const resourceById = Object.values(this.config.resourceManager.resourceById); - const resourcesLoaders: ResourceLoader[] = resourceById; + const resourcesLoaders = this.resourceManager.resources; let resourcesItems: FormItem[] = resourcesLoaders.map((resourceLoader) => { - const { dataSource, dataAccessor } = resourceLoader; + const { dataAccessor } = resourceLoader; const dataField = resourceLoader.resourceIndex; const name = `${dataField}Editor`; const label = resourceLoader.resourceName ?? dataField; @@ -768,7 +786,7 @@ export class AppointmentForm { colSpan: 1, editorType, editorOptions: { - dataSource, + dataSource: getResourceEditorDataSource(resourceLoader), displayExpr: dataAccessor.textExpr, valueExpr: dataAccessor.idExpr, }, diff --git a/packages/devextreme/js/__internal/scheduler/classes.ts b/packages/devextreme/js/__internal/scheduler/classes.ts index bc75e49b2287..2c841e30927f 100644 --- a/packages/devextreme/js/__internal/scheduler/classes.ts +++ b/packages/devextreme/js/__internal/scheduler/classes.ts @@ -42,9 +42,3 @@ export const GROUP_ROW_CLASS = 'dx-scheduler-group-row'; export const GROUP_HEADER_CONTENT_CLASS = 'dx-scheduler-group-header-content'; export const LAST_GROUP_CELL_CLASS = 'dx-scheduler-last-group-cell'; export const FIRST_GROUP_CELL_CLASS = 'dx-scheduler-first-group-cell'; - -export const VERTICAL_GROUP_COUNT_CLASSES = [ - 'dx-scheduler-group-column-count-one', - 'dx-scheduler-group-column-count-two', - 'dx-scheduler-group-column-count-three', -]; diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel.tsx index bfe4fb8e4a7e..288236f2e39d 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel.tsx @@ -1,6 +1,5 @@ import type { InfernoEffect } from '@ts/core/r1/runtime/inferno/index'; import { createReRenderEffect, InfernoWrapperComponent } from '@ts/core/r1/runtime/inferno/index'; -import type { RefObject } from '@ts/core/r1/types'; import { VERTICAL_GROUP_ORIENTATION } from '../../../constants'; import type { Group, GroupOrientation } from '../../../types'; @@ -14,7 +13,6 @@ import { GroupPanelVertical } from './group_panel_vertical'; export interface GroupPanelProps extends GroupPanelBaseProps { groups: Group[]; groupOrientation: GroupOrientation; - elementRef?: RefObject; } export const GroupPanelDefaultProps: DefaultProps = { @@ -39,7 +37,7 @@ export class GroupPanel extends InfernoWrapperComponent { groupOrientation, groups, styles, - rowHeights, + groupByDate, } = this.props; const isVerticalLayout = isVerticalGroupingApplied(groups.length, groupOrientation); @@ -54,10 +52,7 @@ export class GroupPanel extends InfernoWrapperComponent { groupPanelData={groupPanelData} elementRef={elementRef} styles={styles} - rowHeights={rowHeights} - groups={GroupPanelDefaultProps.groups} - groupOrientation={GroupPanelDefaultProps.groupOrientation} - groupByDate={GroupPanelDefaultProps.groupByDate} + groupByDate={groupByDate} /> ); } diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal.tsx index e4038952313f..ac21886ffe29 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal.tsx @@ -1,65 +1,15 @@ import { BaseInfernoComponent } from '@ts/core/r1/runtime/inferno/index'; -import type { GroupRenderItem } from '../../../types'; import { GroupPanelHorizontalRow } from './group_panel_horizontal_row'; import type { GroupPanelBaseProps } from './group_panel_props'; import { GroupPanelBaseDefaultProps } from './group_panel_props'; export class GroupPanelHorizontal extends BaseInfernoComponent { - private groupPanelItems: GroupRenderItem[][] | null = null; - - getGroupPanelItems(): GroupRenderItem[][] { - if (this.groupPanelItems !== null) { - return this.groupPanelItems; - } - - const { - groupPanelData: { - baseColSpan, - groupPanelItems, - }, - } = this.props; - - const colSpans: number[] = groupPanelItems.reduceRight(( - currentColSpans, - groupsRow, - idx, - ) => { - const nextColSpans = currentColSpans; - const currentLevelGroupCount = groupsRow.length; - const previousColSpan = idx === groupPanelItems.length - 1 - ? baseColSpan - : currentColSpans[idx + 1]; - const previousLevelGroupCount = idx === groupPanelItems.length - 1 - ? currentLevelGroupCount - : groupPanelItems[idx + 1].length; - const groupCountDiff = previousLevelGroupCount / currentLevelGroupCount; - nextColSpans[idx] = groupCountDiff * previousColSpan; - return nextColSpans; - }, [...new Array(groupPanelItems.length)]); - - this.groupPanelItems = groupPanelItems.map((groupsRenderRow, index) => { - const colSpan = colSpans[index]; - return groupsRenderRow.map((groupItem) => ({ - ...groupItem, - colSpan, - })); - }); - - return this.groupPanelItems; - } - - componentWillUpdate(nextProps: GroupPanelBaseProps): void { - if (this.props.groupPanelData !== nextProps.groupPanelData) { - this.groupPanelItems = null; - } - } - render(): JSX.Element { const { + groupPanelData: { groupPanelItems }, resourceCellTemplate, } = this.props; - const groupPanelItems = this.getGroupPanelItems(); return ( <> diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_cell.test.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_cell.test.tsx new file mode 100644 index 000000000000..15fcb63293e9 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_cell.test.tsx @@ -0,0 +1,134 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import type { ResourceCellTemplateData } from '../types'; +import type { GroupPanelHorizontalCellProps } from './group_panel_horizontal_cell'; +import { GroupPanelHorizontalCell } from './group_panel_horizontal_cell'; + +interface VirtualNodeLike { + className?: string; + props?: { + colspan?: number; + rowspan?: number; + title?: string; + scope?: string; + role?: string; + templateProps?: { data: ResourceCellTemplateData }; + }; + children?: VirtualNodeLike | VirtualNodeLike[]; +} + +const baseProps = { + id: 1, + text: 'Room 1', + data: { id: 1, text: 'Room 1' }, + index: 0, + colSpan: 3, + isFirstGroupCell: false, + isLastGroupCell: false, +}; + +describe('GroupPanelHorizontalCell', () => { + it('should render colSpan and omit rowSpan when it is not provided', () => { + const component = new GroupPanelHorizontalCell(baseProps); + const result = component.render() as VirtualNodeLike; + + expect(result.props?.colspan).toBe(3); + expect(result.props?.rowspan).toBeUndefined(); + }); + + it('should render rowSpan when greater than 1 (shallow leaf filling missing depth rows)', () => { + const component = new GroupPanelHorizontalCell({ ...baseProps, rowSpan: 2 }); + const result = component.render() as VirtualNodeLike; + + expect(result.props?.rowspan).toBe(2); + }); + + it('should omit rowSpan attribute when it equals 1', () => { + const component = new GroupPanelHorizontalCell({ ...baseProps, rowSpan: 1 }); + const result = component.render() as VirtualNodeLike; + + expect(result.props?.rowspan).toBeUndefined(); + }); + + it('should set a title attribute with the cell text for overflow tooltips', () => { + const component = new GroupPanelHorizontalCell(baseProps); + const result = component.render() as VirtualNodeLike; + + expect(result.props?.title).toBe('Room 1'); + }); + + it('should set scope="colgroup" when colSpan is greater than 1', () => { + const component = new GroupPanelHorizontalCell(baseProps); + const result = component.render() as VirtualNodeLike; + + expect(result.props?.scope).toBe('colgroup'); + expect(result.props?.role).toBe('columnheader'); + }); + + it('should set scope="col" when colSpan equals 1', () => { + const component = new GroupPanelHorizontalCell({ ...baseProps, colSpan: 1 }); + const result = component.render() as VirtualNodeLike; + + expect(result.props?.scope).toBe('col'); + expect(result.props?.role).toBe('columnheader'); + }); + + it('should keep the group separator border on a cell that does not reach the last column', () => { + const component = new GroupPanelHorizontalCell({ ...baseProps, isLastColumn: false }); + const result = component.render() as VirtualNodeLike; + + expect(result.className).toContain('dx-scheduler-group-header-inner-column'); + }); + + it('should not mark a cell that reaches the last column', () => { + const component = new GroupPanelHorizontalCell({ ...baseProps, isLastColumn: true }); + const result = component.render() as VirtualNodeLike; + + expect(result.className).not.toContain('dx-scheduler-group-header-inner-column'); + }); + + describe('resourceCellTemplate', () => { + const cellTemplate = (): JSX.Element =>
; + + const renderTemplateData = ( + props: Partial, + ): ResourceCellTemplateData => { + const result = new GroupPanelHorizontalCell({ + ...baseProps, ...props, cellTemplate, + }).render() as VirtualNodeLike; + const content = result.children as VirtualNodeLike; + const templateNode = (Array.isArray(content.children) + ? content.children[0] + : content.children) as VirtualNodeLike; + + return templateNode.props?.templateProps?.data as ResourceCellTemplateData; + }; + + it('should pass hierarchy-aware data to a parent header cell template', () => { + const buildingPathItem = { + id: 'A', text: 'Building A', resourceIndex: 'buildingId', data: { id: 'A', text: 'Building A' }, + }; + const templateData = renderTemplateData({ + id: 'A', + text: 'Building A', + data: { id: 'A', text: 'Building A' }, + resourceIndex: 'buildingId', + isLeaf: false, + path: [buildingPathItem], + }); + + expect(templateData).toEqual({ + data: { id: 'A', text: 'Building A' }, + id: 'A', + text: 'Building A', + color: undefined, + resourceIndex: 'buildingId', + level: 0, + isLeaf: false, + path: [buildingPathItem], + }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_cell.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_cell.tsx index e08c3c579aca..613320bbfa66 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_cell.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_cell.tsx @@ -3,6 +3,7 @@ import { PublicTemplate } from '@ts/scheduler/r1/components/templates/index'; import type { ResourceCellTemplateProps } from '@ts/scheduler/r1/components/types'; import { combineClasses } from '../../../../core/r1/utils/render_utils'; +import { getResourceCellTemplateData } from '../../utils/group_panel_tree'; import type { GroupPanelCellProps } from './group_panel_props'; import { GroupPanelCellDefaultProps } from './group_panel_props'; @@ -10,6 +11,8 @@ export interface GroupPanelHorizontalCellProps extends GroupPanelCellProps { isFirstGroupCell: boolean; isLastGroupCell: boolean; colSpan: number; + rowSpan?: number; + isLastColumn?: boolean; } export const GroupPanelHorizontalCellDefaultProps = { @@ -24,6 +27,7 @@ export class GroupPanelHorizontalCell extends BaseInfernoComponent 1 ? 'colgroup' : 'col'; + return ( 1 ? rowSpan : undefined} + title={text} + scope={scope} + role="columnheader" >
{ @@ -51,12 +66,9 @@ export class GroupPanelHorizontalCell extends BaseInfernoComponent diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_row.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_row.tsx index 7d75748689fe..d973823134a4 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_row.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_horizontal_row.tsx @@ -17,13 +17,18 @@ export class GroupPanelHorizontalRow extends BaseInfernoComponent ) } diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_props.ts b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_props.ts index ecfa7c1393e4..204e2fdd4a84 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_props.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_props.ts @@ -1,7 +1,10 @@ import type { PropsWithClassName, PropsWithStyles } from '@ts/core/r1/index'; -import type { JSXTemplate } from '@ts/core/r1/types'; +import type { JSXTemplate, RefObject } from '@ts/core/r1/types'; -import type { GroupItem, GroupPanelData, GroupRenderItem } from '../../../types'; +import type { + GroupHeaderHierarchy, GroupItem, GroupPanelData, GroupRenderItem, +} from '../../../types'; +import type { ResourceId } from '../../../utils/loader/types'; import type { DefaultProps, PropsWithViewContext, ResourceCellTemplateProps } from '../types'; export interface GroupPanelBaseProps extends @@ -11,24 +14,29 @@ export interface GroupPanelBaseProps extends groupPanelData: GroupPanelData; groupByDate: boolean; height?: number; - rowHeights?: number[]; + elementRef?: RefObject; resourceCellTemplate?: JSXTemplate; } export const GroupPanelBaseDefaultProps: DefaultProps = { groupPanelData: { + groupTree: [], groupPanelItems: [], + maxDepth: 0, baseColSpan: 1, + columnCountPerGroup: 1, + hasHierarchy: false, }, groupByDate: false, styles: {}, }; -export interface GroupPanelCellProps extends PropsWithClassName { - id: string | number; +export interface GroupPanelCellProps extends PropsWithClassName, Partial { + id: ResourceId; text?: string; color?: string; data: GroupItem; + resourceIndex?: string; index: number; cellTemplate?: JSXTemplate; } @@ -44,7 +52,6 @@ export const GroupPanelCellDefaultProps = { export interface GroupPanelRowProps extends PropsWithClassName { groupItems: GroupRenderItem[]; - height?: number; cellTemplate?: JSXTemplate; } diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical.tsx index fe2749291431..9ad01ccffad4 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical.tsx @@ -1,10 +1,61 @@ import { BaseInfernoComponent, normalizeStyles } from '@ts/core/r1/runtime/inferno/index'; -import { renderUtils } from '../../utils/index'; +import { getTimelineGroupPanelRows, renderUtils } from '../../utils/index'; import type { GroupPanelProps } from './group_panel'; import { GroupPanelBaseDefaultProps } from './group_panel_props'; +import { GroupPanelVerticalCell } from './group_panel_vertical_cell'; +import { GroupPanelVerticalNode } from './group_panel_vertical_node'; import { GroupPanelVerticalRow } from './group_panel_vertical_row'; +const HIERARCHICAL_GROUP_FLEX_CONTAINER_CLASS = 'dx-scheduler-group-flex-container-hierarchical'; +const TIMELINE_GROUP_TABLE_CLASS = 'dx-scheduler-group-table'; + +const renderGroupPanelContent = ( + groupPanelData: GroupPanelProps['groupPanelData'], + resourceCellTemplate: GroupPanelProps['resourceCellTemplate'], + isTimelineGroupTable: boolean, + isHierarchical: boolean, + groupByDate: boolean, +): JSX.Element | JSX.Element[] => { + if (isTimelineGroupTable && !isHierarchical) { + return getTimelineGroupPanelRows(groupPanelData, groupByDate) + .map((group) => ); + } + + if (isHierarchical) { + return groupPanelData.groupTree + .map((node, index) => ); + } + + return ( +
+ { + groupPanelData.groupTree.map((node, index) => ) + } +
+ ); +}; + export class GroupPanelVertical extends BaseInfernoComponent { render(): JSX.Element { const { @@ -14,9 +65,24 @@ export class GroupPanelVertical extends BaseInfernoComponent { resourceCellTemplate, height, styles, - rowHeights, + groupByDate, } = this.props; const style = normalizeStyles(renderUtils.addHeightToStyle(height, styles)); + const isTimelineGroupTable = className === TIMELINE_GROUP_TABLE_CLASS; + const useResourceHierarchyLayout = groupPanelData.hasHierarchy && groupPanelData.maxDepth > 1; + const isHierarchical = isTimelineGroupTable + ? useResourceHierarchyLayout + : groupPanelData.maxDepth > 1; + const flexContainerClassName = isHierarchical + ? `dx-scheduler-group-flex-container ${HIERARCHICAL_GROUP_FLEX_CONTAINER_CLASS}` + : 'dx-scheduler-group-flex-container'; + const groupPanelContent = renderGroupPanelContent( + groupPanelData, + resourceCellTemplate, + isTimelineGroupTable, + isHierarchical, + groupByDate, + ); return (
{ className={className} style={style} > -
- { - groupPanelData.groupPanelItems - .map((group, index) => ) - } +
+ {groupPanelContent}
); diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_cell.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_cell.tsx index 8db4f47d8e6b..8037e5ed4821 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_cell.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_cell.tsx @@ -2,6 +2,7 @@ import { BaseInfernoComponent } from '@ts/core/r1/runtime/inferno/index'; import { PublicTemplate } from '@ts/scheduler/r1/components/templates/index'; import type { ResourceCellTemplateProps } from '@ts/scheduler/r1/components/types'; +import { getResourceCellTemplateData } from '../../utils/group_panel_tree'; import type { GroupPanelCellProps } from './group_panel_props'; import { GroupPanelCellDefaultProps } from './group_panel_props'; @@ -15,21 +16,21 @@ export class GroupPanelVerticalCell extends BaseInfernoComponent +
{ cellTemplate ? diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_node.test.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_node.test.tsx new file mode 100644 index 000000000000..b9d67d09a764 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_node.test.tsx @@ -0,0 +1,142 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import type { GroupPanelTreeNode } from '../../../types'; +import { buildGroupPanelTree } from '../../utils/group_panel_tree'; +import type { ResourceCellTemplateData } from '../types'; +import { GroupPanelVerticalNode } from './group_panel_vertical_node'; + +interface VirtualNodeLike { + className?: string; + props?: Record; + children?: VirtualNodeLike | VirtualNodeLike[]; +} + +const leafNode = ( + key: string, + text: string, + leafCount: number, +): GroupPanelTreeNode => ({ + key, + id: key, + text, + data: { id: key, text }, + resourceIndex: 'roomId', + path: [{ + id: key, text, color: undefined, resourceIndex: 'roomId', data: { id: key, text }, + }], + leafCount, + children: [], +}); + +describe('GroupPanelVerticalNode', () => { + it('should set flexGrow from leafCount so height is proportional to descendant leaves', () => { + const component = new GroupPanelVerticalNode({ node: leafNode('a', 'Room A', 3), index: 0 }); + const result = component.render() as VirtualNodeLike; + + expect(result.props?.style).toEqual({ 'flex-grow': 3 }); + }); + + it('should mark a childless node as a leaf cell and render no nested children container', () => { + const component = new GroupPanelVerticalNode({ node: leafNode('a', 'Room A', 1), index: 0 }); + const result = component.render() as VirtualNodeLike; + const children = result.children as VirtualNodeLike[]; + + expect(children).toHaveLength(1); + expect(children[0].className).toContain('dx-scheduler-group-header-leaf'); + }); + + it('should set title/aria-label for the accessible label and overflow tooltip', () => { + const component = new GroupPanelVerticalNode({ node: leafNode('a', 'Room A', 1), index: 0 }); + const result = component.render() as VirtualNodeLike; + const children = result.children as VirtualNodeLike[]; + const cell = children[0]; + + expect(cell.props?.title).toBe('Room A'); + expect(cell.props?.['aria-label']).toBe('Room A'); + }); + + it('should recurse into children via a nested flex container when the node has children', () => { + const parent: GroupPanelTreeNode = { + ...leafNode('parent', 'Building A', 2), + children: [leafNode('child1', 'Room A1', 1), leafNode('child2', 'Room A2', 1)], + }; + const component = new GroupPanelVerticalNode({ node: parent, index: 0 }); + const result = component.render() as VirtualNodeLike; + const children = result.children as VirtualNodeLike[]; + + expect(children).toHaveLength(2); + expect(children[0].className).not.toContain('dx-scheduler-group-header-leaf'); + + const nestedContainer = children[1]; + expect(nestedContainer.className).toBe('dx-scheduler-group-flex-container'); + expect(nestedContainer.children).toHaveLength(2); + }); + + describe('resourceCellTemplate', () => { + const cellTemplate = (): JSX.Element =>
; + const hierarchy = buildGroupPanelTree([ + { + id: 'A', + resourceText: 'Building A', + resourceIndex: 'buildingId', + grouped: { buildingId: 'A' }, + children: [{ + id: 1, + resourceText: 'Room A1', + color: '#aaa', + resourceIndex: 'roomId', + grouped: { buildingId: 'A', roomId: 1 }, + children: [], + }], + }, + ]); + const [building] = hierarchy; + const [room] = building.children; + + const renderTemplateData = ( + node: GroupPanelTreeNode, + index: number, + ): ResourceCellTemplateData => { + const result = new GroupPanelVerticalNode({ node, index, cellTemplate }).render(); + const cell = (result as VirtualNodeLike).children as VirtualNodeLike[]; + const templateNode = (Array.isArray(cell[0].children) + ? cell[0].children[0] + : cell[0].children) as VirtualNodeLike; + + return (templateNode.props as { + templateProps: { data: ResourceCellTemplateData }; + }).templateProps.data; + }; + + it('should pass hierarchy-aware data to a parent header cell template', () => { + expect(renderTemplateData(building, 0)).toEqual({ + data: { id: 'A', text: 'Building A' }, + id: 'A', + text: 'Building A', + color: undefined, + resourceIndex: 'buildingId', + level: 0, + isLeaf: false, + path: [expect.objectContaining({ id: 'A', text: 'Building A' })], + }); + }); + + it('should pass hierarchy-aware data to a leaf header cell template', () => { + expect(renderTemplateData(room, 0)).toEqual({ + data: { id: 1, text: 'Room A1', color: '#aaa' }, + id: 1, + text: 'Room A1', + color: '#aaa', + resourceIndex: 'roomId', + level: 1, + isLeaf: true, + path: [ + expect.objectContaining({ text: 'Building A' }), + expect.objectContaining({ text: 'Room A1' }), + ], + }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_node.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_node.tsx new file mode 100644 index 000000000000..b5ec17e6f355 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_node.tsx @@ -0,0 +1,90 @@ +import type { PropsWithClassName } from '@ts/core/r1/index'; +import { BaseInfernoComponent, normalizeStyles } from '@ts/core/r1/runtime/inferno/index'; +import type { JSXTemplate } from '@ts/core/r1/types'; +import { PublicTemplate } from '@ts/scheduler/r1/components/templates/index'; +import type { ResourceCellTemplateProps } from '@ts/scheduler/r1/components/types'; + +import { combineClasses } from '../../../../core/r1/utils/render_utils'; +import type { GroupPanelTreeNode } from '../../../types'; +import { getResourceCellTemplateData } from '../../utils/group_panel_tree'; + +export interface GroupPanelVerticalNodeProps extends Partial { + node: GroupPanelTreeNode; + index: number; + cellTemplate?: JSXTemplate; +} + +export const GroupPanelVerticalNodeDefaultProps: GroupPanelVerticalNodeProps = { + node: { + key: '', + id: 0, + text: '', + data: { id: 0 }, + resourceIndex: '', + path: [], + leafCount: 1, + children: [], + }, + index: 0, + className: '', +}; + +export class GroupPanelVerticalNode extends BaseInfernoComponent { + render(): JSX.Element { + const { + node, index, cellTemplate, className, + } = this.props; + const isLeaf = node.children.length === 0; + + const rowClasses = combineClasses({ + 'dx-scheduler-group-row': true, + [className ?? '']: Boolean(className), + }); + const cellClasses = combineClasses({ + 'dx-scheduler-group-header': true, + 'dx-scheduler-group-header-leaf': isLeaf, + }); + + return ( +
+
+ { + cellTemplate + ? + : ( +
+ {node.text} +
+ ) + } +
+ { + !isLeaf && ( +
+ { + node.children.map((child, childIndex) => ) + } +
+ ) + } +
+ ); + } +} + +GroupPanelVerticalNode.defaultProps = GroupPanelVerticalNodeDefaultProps; diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.test.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.test.tsx index 5bf6832f691b..087d3c95f0fa 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.test.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.test.tsx @@ -1,48 +1,39 @@ import { - describe, expect, it, jest, + describe, expect, it, } from '@jest/globals'; import { GroupPanelVerticalRow } from './group_panel_vertical_row'; -interface RenderUtilsMock { - renderUtils: { - addHeightToStyle: ( - height: number | undefined, - styles?: Record, - ) => Record; - }; +interface VirtualNodeLike { + className?: string; + children?: VirtualNodeLike | VirtualNodeLike[]; } -jest.mock('../../utils/index', (): RenderUtilsMock => ({ - renderUtils: { - addHeightToStyle: ( - height: number | undefined, - styles: Record = {}, - ): Record => (height === undefined ? styles : { ...styles, height }), - }, -})); +const toChildrenArray = ( + children: VirtualNodeLike | VirtualNodeLike[] | undefined, +): VirtualNodeLike[] => { + if (!children) { + return []; + } -interface VirtualNodeLike { - props?: { - style?: unknown; - }; -} + return Array.isArray(children) ? children : [children]; +}; describe('GroupPanelVerticalRow', () => { - it('should apply row height', () => { + it('should render one group row with a cell per group item', () => { const component = new GroupPanelVerticalRow({ - groupItems: [{ - key: '0', - id: 0, - text: 'Group 0', - data: { id: 0 }, - resourceName: 'ownerId', - }], - height: 140, - className: '', + groupItems: [ + { + id: 1, text: 'a', key: 'one_1', resourceIndex: 'one', data: { id: 1, text: 'a' }, colSpan: 2, isLeaf: true, path: [], + }, + { + id: 2, text: 'b', key: 'one_2', resourceIndex: 'one', data: { id: 2, text: 'b' }, colSpan: 2, isLeaf: true, path: [], + }, + ], }); const result = component.render() as VirtualNodeLike; - expect(result.props?.style).toEqual({ height: '140px' }); + expect(result.className).toContain('dx-scheduler-group-row'); + expect(toChildrenArray(result.children)).toHaveLength(2); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.tsx index 024e9f102f95..0eb6e689a99c 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.tsx +++ b/packages/devextreme/js/__internal/scheduler/r1/components/base/group_panel_vertical_row.tsx @@ -1,6 +1,5 @@ -import { BaseInfernoComponent, normalizeStyles } from '@ts/core/r1/runtime/inferno/index'; +import { BaseInfernoComponent } from '@ts/core/r1/runtime/inferno/index'; -import { renderUtils } from '../../utils/index'; import type { GroupPanelRowProps } from './group_panel_props'; import { GroupPanelRowDefaultProps } from './group_panel_props'; import { GroupPanelVerticalCell } from './group_panel_vertical_cell'; @@ -10,18 +9,11 @@ export class GroupPanelVerticalRow extends BaseInfernoComponent +
{ groupItems.map(({ color, @@ -29,6 +21,9 @@ export class GroupPanelVerticalRow extends BaseInfernoComponent ) } diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/timeline/header_panel_timeline.test.tsx b/packages/devextreme/js/__internal/scheduler/r1/components/timeline/header_panel_timeline.test.tsx new file mode 100644 index 000000000000..071cefa0c532 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/r1/components/timeline/header_panel_timeline.test.tsx @@ -0,0 +1,82 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import type { GroupPanelData } from '../../../types'; +import type { GroupNode } from '../../../utils/resource_manager/types'; +import { getGroupPanelData } from '../../utils/base'; +import { HeaderPanel } from '../base/header_panel'; +import { HeaderPanelTimeline } from './header_panel_timeline'; + +interface VirtualNodeLike { + type?: { name?: string } | string; + props?: Record; +} + +const hierarchicalGroupsTree: GroupNode[] = [ + { + id: 'A', + resourceText: 'Building A', + resourceIndex: 'buildingId', + grouped: { buildingId: 'A' }, + children: [ + { + id: 1, + resourceText: 'Room A1', + resourceIndex: 'roomId', + grouped: { buildingId: 'A', roomId: 1 }, + children: [], + }, + { + id: 2, + resourceText: 'Room A2', + resourceIndex: 'roomId', + grouped: { buildingId: 'A', roomId: 2 }, + children: [], + }, + ], + }, +]; + +const hierarchicalGroupPanelData: GroupPanelData = getGroupPanelData( + hierarchicalGroupsTree, + 1, + false, + 1, + true, +); + +const baseProps = { + groupPanelData: hierarchicalGroupPanelData, + groups: [{ name: 'buildingId', items: [], data: [] }], + groupByDate: false, + isRenderDateHeader: true, + dateHeaderData: { + dataMap: [], leftVirtualCellCount: 0, rightVirtualCellCount: 0, + }, +}; + +describe('HeaderPanelTimeline', () => { + it('should delegate a hierarchical groupPanelData through to the shared HeaderPanel unchanged', () => { + const component = new HeaderPanelTimeline({ + ...baseProps, + groupOrientation: 'horizontal', + } as any); + const result = component.render() as VirtualNodeLike; + + expect((result.type as { name?: string })?.name).toBe(HeaderPanel.name); + expect(result.props?.groupPanelData).toBe(hierarchicalGroupPanelData); + expect(result.props?.groupOrientation).toBe('horizontal'); + }); + + it('should still delegate correctly for vertical grouping (sidebar-driven, no header GroupPanel)', () => { + const component = new HeaderPanelTimeline({ + ...baseProps, + groupOrientation: 'vertical', + } as any); + const result = component.render() as VirtualNodeLike; + + expect(result.props?.groupPanelData).toBe(hierarchicalGroupPanelData); + expect(result.props?.groupOrientation).toBe('vertical'); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/r1/components/types.ts b/packages/devextreme/js/__internal/scheduler/r1/components/types.ts index e710f36c81db..9be55b1aeaa4 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/components/types.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/components/types.ts @@ -1,7 +1,10 @@ import type { JSXTemplate } from '@ts/core/r1/types'; import type { GroupLeaf } from '@ts/scheduler/utils/resource_manager/types'; -import type { GroupItem, ViewDataBase, ViewType } from '../../types'; +import type { + GroupHeaderPathItem, GroupItem, ViewDataBase, ViewType, +} from '../../types'; +import type { ResourceId } from '../../utils/loader/types'; export interface BaseTemplateProps { index: number; @@ -32,11 +35,15 @@ export interface DateTimeCellTemplateProps extends BaseTemplateProps { data: DateCellTemplateData; } -interface ResourceCellTemplateData { +export interface ResourceCellTemplateData { data: GroupItem; - id: number | string; + id: ResourceId; text?: string; color?: string; + resourceIndex: string; + level: number; + isLeaf: boolean; + path: GroupHeaderPathItem[]; } export interface ResourceCellTemplateProps extends BaseTemplateProps { diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/base.test.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/base.test.ts index 4f53f96fdf21..4cb270ff3656 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/base.test.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/base.test.ts @@ -3,7 +3,8 @@ import { } from '@jest/globals'; import { HORIZONTAL_GROUP_ORIENTATION, VERTICAL_GROUP_ORIENTATION } from '@ts/scheduler/constants'; -import type { ViewType } from '../../../types'; +import type { GroupHeaderPathItem, GroupRenderItem, ViewType } from '../../../types'; +import type { GroupNode } from '../../../utils/resource_manager/types'; import { getAppointmentKey, getCellDuration, @@ -12,6 +13,7 @@ import { getIsGroupedAllDayPanel, getKeyByGroup, getSkippedHoursInRange, + getTimelineGroupPanelRows, isAppointmentTakesAllDay, isGroupingByDate, isHorizontalGroupingApplied, @@ -434,218 +436,269 @@ describe('base utils', () => { }); describe('getGroupPanelData', () => { - const groupsBase: any = [{ - resourceIndex: 'group1', - resourceName: 'group 1', - items: [{ - text: 'item 1', id: 1, color: 'color 1', - }, { - text: 'item 2', id: 2, color: 'color 2', - }], - data: [{ - text: 'item 1', id: 1, color: 'color 1', - }, { - text: 'item 2', id: 2, color: 'color 2', - }], - }, { - resourceIndex: 'group2', - resourceName: 'group 2', - items: [{ - text: 'item 3', id: 1, color: 'color 3', - }, { - text: 'item 4', id: 2, color: 'color 4', - }], - data: [{ - text: 'item 3', id: 1, color: 'color 3', - }, { - text: 'item 4', id: 2, color: 'color 4', - }], - }]; - - it('should transform grouping data into group items', () => { - const groupPanelData = getGroupPanelData(groupsBase, 1, false, 3); - - expect(groupPanelData) - .toEqual({ - groupPanelItems: [[{ - ...groupsBase[0].items[0], - data: groupsBase[0].data[0], - resourceName: groupsBase[0].resourceName, - key: '0_group1_1', - }, { - ...groupsBase[0].items[1], - data: groupsBase[0].data[1], - resourceName: groupsBase[0].resourceName, - key: '0_group1_2', - }], [{ - ...groupsBase[1].items[0], - data: groupsBase[1].data[0], - resourceName: groupsBase[1].resourceName, - key: '0_group2_1', - }, { - ...groupsBase[1].items[1], - data: groupsBase[1].data[1], - resourceName: groupsBase[1].resourceName, - key: '0_group2_2', - }, { - ...groupsBase[1].items[0], - data: groupsBase[1].data[0], - resourceName: groupsBase[1].resourceName, - key: '1_group2_1', - }, { - ...groupsBase[1].items[1], - data: groupsBase[1].data[1], - resourceName: groupsBase[1].resourceName, - key: '1_group2_2', - }]], - baseColSpan: 3, - }); + // group1 (2 items) x group2 (2 items), uniform 2-level tree + const groupsTreeBase: GroupNode[] = [ + { + id: 1, + resourceText: 'item 1', + color: 'color 1', + resourceIndex: 'group1', + grouped: { group1: 1 }, + children: [ + { + id: 1, resourceText: 'item 3', color: 'color 3', resourceIndex: 'group2', grouped: { group1: 1, group2: 1 }, children: [], + }, + { + id: 2, resourceText: 'item 4', color: 'color 4', resourceIndex: 'group2', grouped: { group1: 1, group2: 2 }, children: [], + }, + ], + }, + { + id: 2, + resourceText: 'item 2', + color: 'color 2', + resourceIndex: 'group1', + grouped: { group1: 2 }, + children: [ + { + id: 1, resourceText: 'item 3', color: 'color 3', resourceIndex: 'group2', grouped: { group1: 2, group2: 1 }, children: [], + }, + { + id: 2, resourceText: 'item 4', color: 'color 4', resourceIndex: 'group2', grouped: { group1: 2, group2: 2 }, children: [], + }, + ], + }, + ]; + + type AncestorArgs = [ + id: number | string, + text: string, + color: string | undefined, + resourceIndex: string, + ]; + + const pathItem = ( + [id, text, color, resourceIndex]: AncestorArgs, + ): GroupHeaderPathItem => ({ + id, + text, + color, + resourceIndex, + data: { id, text, color }, }); - it('should work when data parameter is undefined', () => { - const groups = [{ - resourceIndex: 'group1', - resourceName: 'group 1', - items: [{ - text: 'item 1', id: 1, color: 'color 1', - }, { - text: 'item 2', id: 2, color: 'color 2', - }], - }] as any; - const groupPanelData = getGroupPanelData(groups, 1, false, 5); + const renderItem = ( + id: number | string, + text: string, + color: string | undefined, + key: string, + resourceIndex: string, + colSpan: number, + extra: Partial = {}, + ancestors: AncestorArgs[] = [], + ): GroupRenderItem => ({ + id, + text, + color, + key, + resourceIndex, + data: { id, text, color }, + colSpan, + isLeaf: true, + isLastColumn: false, + path: [...ancestors, [id, text, color, resourceIndex] as AncestorArgs].map(pathItem), + ...extra, + }); - expect(groupPanelData) - .toEqual({ - groupPanelItems: [[{ - ...groups[0].items[0], - resourceName: groups[0].resourceName, - key: '0_group1_1', - }, { - ...groups[0].items[1], - resourceName: groups[0].resourceName, - key: '0_group1_2', - }]], - baseColSpan: 5, - }); + const group1Item1: AncestorArgs = [1, 'item 1', 'color 1', 'group1']; + const group1Item2: AncestorArgs = [2, 'item 2', 'color 2', 'group1']; + + const firstInRepeat = { isFirstGroupCell: true, isLastGroupCell: false }; + const middleInRepeat = { isFirstGroupCell: false, isLastGroupCell: false }; + const lastInRepeat = { isFirstGroupCell: false, isLastGroupCell: true }; + + it('should transform a uniform-depth tree into per-depth rows with real colSpan', () => { + const groupPanelData = getGroupPanelData(groupsTreeBase, 1, false, 3); + + expect(groupPanelData.maxDepth).toBe(2); + expect(groupPanelData.baseColSpan).toBe(3); + expect(groupPanelData.groupTree[0].leafCount).toBe(2); + expect(groupPanelData.groupTree[0].children[0].leafCount).toBe(1); + expect(groupPanelData.groupPanelItems).toEqual([ + [ + renderItem(1, 'item 1', 'color 1', 'group1_1', 'group1', 6, { isLeaf: false }), + renderItem(2, 'item 2', 'color 2', 'group1_2', 'group1', 6, { isLeaf: false, isLastColumn: true }), + ], + [ + renderItem(1, 'item 3', 'color 3', 'group1_1_group2_1', 'group2', 3, {}, [group1Item1]), + renderItem(2, 'item 4', 'color 4', 'group1_1_group2_2', 'group2', 3, {}, [group1Item1]), + renderItem(1, 'item 3', 'color 3', 'group1_2_group2_1', 'group2', 3, {}, [group1Item2]), + renderItem(2, 'item 4', 'color 4', 'group1_2_group2_2', 'group2', 3, { isLastColumn: true }, [group1Item2]), + ], + ]); }); - it('should exclude zero items resources', () => { - const groups = [{ - resourceIndex: 'group1', - resourceName: 'group 1', - items: [{ - text: 'item 1', id: 1, color: 'color 1', - }], - }, { - resourceIndex: 'group2', - resourceName: 'group 2', - items: [], - }] as any; + it('should work for a single-level tree (maxDepth 1, no rowSpan)', () => { + const groups: GroupNode[] = [ + { + id: 1, resourceText: 'item 1', color: 'color 1', resourceIndex: 'group1', grouped: { group1: 1 }, children: [], + }, + { + id: 2, resourceText: 'item 2', color: 'color 2', resourceIndex: 'group1', grouped: { group1: 2 }, children: [], + }, + ]; const groupPanelData = getGroupPanelData(groups, 1, false, 5); - expect(groupPanelData) - .toEqual({ - groupPanelItems: [[{ - ...groups[0].items[0], - resourceName: groups[0].resourceName, - key: '0_group1_1', - }]], - baseColSpan: 5, - }); + expect(groupPanelData.maxDepth).toBe(1); + expect(groupPanelData.groupPanelItems).toEqual([ + [ + renderItem(1, 'item 1', 'color 1', 'group1_1', 'group1', 5), + renderItem(2, 'item 2', 'color 2', 'group1_2', 'group1', 5, { isLastColumn: true }), + ], + ]); }); - it('should transform grouping data into group items corectly when appointments are groupped by date', () => { - const groupPanelData = getGroupPanelData(groupsBase, 2, true, 7); - - expect(groupPanelData) - .toEqual({ - groupPanelItems: [[{ - ...groupsBase[0].items[0], - data: groupsBase[0].data[0], - resourceName: groupsBase[0].resourceName, - key: '0_group1_1_group_by_date_0', - isFirstGroupCell: true, - isLastGroupCell: false, - }, { - ...groupsBase[0].items[1], - data: groupsBase[0].data[1], - resourceName: groupsBase[0].resourceName, - key: '0_group1_2_group_by_date_0', - isFirstGroupCell: false, - isLastGroupCell: true, - }, { - ...groupsBase[0].items[0], - data: groupsBase[0].data[0], - resourceName: groupsBase[0].resourceName, - key: '0_group1_1_group_by_date_1', - isFirstGroupCell: true, - isLastGroupCell: false, - }, { - ...groupsBase[0].items[1], - data: groupsBase[0].data[1], - resourceName: groupsBase[0].resourceName, - key: '0_group1_2_group_by_date_1', - isFirstGroupCell: false, - isLastGroupCell: true, - }], [{ - ...groupsBase[1].items[0], - data: groupsBase[1].data[0], - resourceName: groupsBase[1].resourceName, - key: '0_group2_1_group_by_date_0', - isFirstGroupCell: true, - isLastGroupCell: false, - }, { - ...groupsBase[1].items[1], - data: groupsBase[1].data[1], - resourceName: groupsBase[1].resourceName, - key: '0_group2_2_group_by_date_0', - isFirstGroupCell: false, - isLastGroupCell: false, - }, { - ...groupsBase[1].items[0], - data: groupsBase[1].data[0], - resourceName: groupsBase[1].resourceName, - key: '1_group2_1_group_by_date_0', - isFirstGroupCell: false, - isLastGroupCell: false, - }, { - ...groupsBase[1].items[1], - data: groupsBase[1].data[1], - resourceName: groupsBase[1].resourceName, - key: '1_group2_2_group_by_date_0', - isFirstGroupCell: false, - isLastGroupCell: true, - }, { - ...groupsBase[1].items[0], - data: groupsBase[1].data[0], - resourceName: groupsBase[1].resourceName, - key: '0_group2_1_group_by_date_1', - isFirstGroupCell: true, - isLastGroupCell: false, - }, { - ...groupsBase[1].items[1], - data: groupsBase[1].data[1], - resourceName: groupsBase[1].resourceName, - key: '0_group2_2_group_by_date_1', - isFirstGroupCell: false, - isLastGroupCell: false, - }, { - ...groupsBase[1].items[0], - data: groupsBase[1].data[0], - resourceName: groupsBase[1].resourceName, - key: '1_group2_1_group_by_date_1', - isFirstGroupCell: false, - isLastGroupCell: false, - }, { - ...groupsBase[1].items[1], - data: groupsBase[1].data[1], - resourceName: groupsBase[1].resourceName, - key: '1_group2_2_group_by_date_1', - isFirstGroupCell: false, - isLastGroupCell: true, - }]], - baseColSpan: 7, - }); + it('should fill a childless (shallower) branch down via rowSpan for non-uniform depth', () => { + const groups: GroupNode[] = [ + { + id: 'A', + resourceText: 'Building A', + resourceIndex: 'buildingId', + grouped: { buildingId: 'A' }, + children: [ + { + id: 1, resourceText: 'Room A1', resourceIndex: 'roomId', grouped: { buildingId: 'A', roomId: 1 }, children: [], + }, + ], + }, + { + id: 'B', resourceText: 'Building B', resourceIndex: 'buildingId', grouped: { buildingId: 'B' }, children: [], + }, + ]; + const groupPanelData = getGroupPanelData(groups, 1, false, 1); + + expect(groupPanelData.maxDepth).toBe(2); + expect(groupPanelData.groupPanelItems).toEqual([ + [ + renderItem('A', 'Building A', undefined, 'buildingId_A', 'buildingId', 1, { isLeaf: false }), + renderItem('B', 'Building B', undefined, 'buildingId_B', 'buildingId', 1, { rowSpan: 2, isLastColumn: true }), + ], + [ + renderItem(1, 'Room A1', undefined, 'buildingId_A_roomId_1', 'roomId', 1, {}, [['A', 'Building A', undefined, 'buildingId']]), + ], + ]); + }); + + it('should transform grouping data into group items correctly when appointments are grouped by date', () => { + const groupPanelData = getGroupPanelData(groupsTreeBase, 2, true, 7); + + expect(groupPanelData.groupPanelItems).toEqual([ + [ + renderItem(1, 'item 1', 'color 1', 'group1_1_group_by_date_0', 'group1', 14, { isLeaf: false, ...firstInRepeat }), + renderItem(2, 'item 2', 'color 2', 'group1_2_group_by_date_0', 'group1', 14, { isLeaf: false, isLastColumn: true, ...lastInRepeat }), + renderItem(1, 'item 1', 'color 1', 'group1_1_group_by_date_1', 'group1', 14, { isLeaf: false, ...firstInRepeat }), + renderItem(2, 'item 2', 'color 2', 'group1_2_group_by_date_1', 'group1', 14, { isLeaf: false, isLastColumn: true, ...lastInRepeat }), + ], + [ + renderItem(1, 'item 3', 'color 3', 'group1_1_group2_1_group_by_date_0', 'group2', 7, { ...firstInRepeat }, [group1Item1]), + renderItem(2, 'item 4', 'color 4', 'group1_1_group2_2_group_by_date_0', 'group2', 7, { ...middleInRepeat }, [group1Item1]), + renderItem(1, 'item 3', 'color 3', 'group1_2_group2_1_group_by_date_0', 'group2', 7, { ...middleInRepeat }, [group1Item2]), + renderItem(2, 'item 4', 'color 4', 'group1_2_group2_2_group_by_date_0', 'group2', 7, { isLastColumn: true, ...lastInRepeat }, [group1Item2]), + renderItem(1, 'item 3', 'color 3', 'group1_1_group2_1_group_by_date_1', 'group2', 7, { ...firstInRepeat }, [group1Item1]), + renderItem(2, 'item 4', 'color 4', 'group1_1_group2_2_group_by_date_1', 'group2', 7, { ...middleInRepeat }, [group1Item1]), + renderItem(1, 'item 3', 'color 3', 'group1_2_group2_1_group_by_date_1', 'group2', 7, { ...middleInRepeat }, [group1Item2]), + renderItem(2, 'item 4', 'color 4', 'group1_2_group2_2_group_by_date_1', 'group2', 7, { isLastColumn: true, ...lastInRepeat }, [group1Item2]), + ], + ]); + expect(groupPanelData.baseColSpan).toBe(7); + }); + }); + + describe('getTimelineGroupPanelRows', () => { + it('should keep a single stacked row for flat timeline grouping', () => { + const groups: GroupNode[] = [ + { + id: 0, resourceText: 'Group_0', resourceIndex: 'any', grouped: { any: 0 }, children: [], + }, + { + id: 1, resourceText: 'Group_1', resourceIndex: 'any', grouped: { any: 1 }, children: [], + }, + ]; + const groupPanelData = getGroupPanelData(groups, 1, false, 3); + const timelineRows = getTimelineGroupPanelRows(groupPanelData, false); + + expect(timelineRows).toEqual(groupPanelData.groupPanelItems); + expect(timelineRows).toHaveLength(1); + }); + + it('should keep depth rows for multi-group cartesian timeline grouping', () => { + const groups: GroupNode[] = [ + { + id: 1, + resourceText: 'item 1', + resourceIndex: 'group1', + grouped: { group1: 1 }, + children: [ + { + id: 1, resourceText: 'item 3', resourceIndex: 'group2', grouped: { group1: 1, group2: 1 }, children: [], + }, + { + id: 2, resourceText: 'item 4', resourceIndex: 'group2', grouped: { group1: 1, group2: 2 }, children: [], + }, + ], + }, + { + id: 2, + resourceText: 'item 2', + resourceIndex: 'group1', + grouped: { group1: 2 }, + children: [ + { + id: 1, resourceText: 'item 3', resourceIndex: 'group2', grouped: { group1: 2, group2: 1 }, children: [], + }, + { + id: 2, resourceText: 'item 4', resourceIndex: 'group2', grouped: { group1: 2, group2: 2 }, children: [], + }, + ], + }, + ]; + const groupPanelData = getGroupPanelData(groups, 1, false, 3, false); + const timelineRows = getTimelineGroupPanelRows(groupPanelData, false); + + expect(timelineRows).toEqual(groupPanelData.groupPanelItems); + expect(timelineRows).toHaveLength(2); + }); + + it('should use one row per leaf path for hierarchical timeline grouping', () => { + const groups: GroupNode[] = [ + { + id: 'A', + resourceText: 'Building A', + resourceIndex: 'buildingId', + grouped: { buildingId: 'A' }, + children: [ + { + id: 1, resourceText: 'Room A1', resourceIndex: 'roomId', grouped: { buildingId: 'A', roomId: 1 }, children: [], + }, + ], + }, + { + id: 'B', resourceText: 'Building B', resourceIndex: 'buildingId', grouped: { buildingId: 'B' }, children: [], + }, + ]; + const groupPanelData = getGroupPanelData(groups, 1, false, 1, true); + const timelineRows = getTimelineGroupPanelRows(groupPanelData, false); + + expect(timelineRows).toEqual([ + [ + expect.objectContaining({ key: 'buildingId_A', text: 'Building A' }), + expect.objectContaining({ key: 'buildingId_A_roomId_1', text: 'Room A1' }), + ], + [ + expect.objectContaining({ key: 'buildingId_B', text: 'Building B' }), + ], + ]); + expect(timelineRows).not.toEqual(groupPanelData.groupPanelItems); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/group_panel_tree.test.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/group_panel_tree.test.ts new file mode 100644 index 000000000000..4c7f8137cd21 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/group_panel_tree.test.ts @@ -0,0 +1,441 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import type { ResourceId } from '../../../utils/loader/types'; +import type { GroupNode } from '../../../utils/resource_manager/types'; +import { + buildGroupPanelTree, + flattenGroupPanelTreeToLeafRows, + flattenGroupPanelTreeToRows, + getGroupPanelTreeDepth, + getResourceCellTemplateData, +} from '../group_panel_tree'; + +const node = ( + id: ResourceId, + resourceText: string, + resourceIndex: string, + children: GroupNode[] = [], + color?: string, +): GroupNode => ({ + id, + resourceText, + resourceIndex, + color, + grouped: { [resourceIndex]: id }, + children, +}); + +describe('group_panel_tree', () => { + describe('buildGroupPanelTree', () => { + it('should annotate a flat (single-level) tree', () => { + const tree = [ + node('1', 'Room 1', 'roomId', [], '#aaa'), + node('2', 'Room 2', 'roomId', [], '#ccc'), + ]; + + const result = buildGroupPanelTree(tree); + + expect(result).toEqual([ + { + key: 'roomId_1', + id: '1', + text: 'Room 1', + color: '#aaa', + data: { id: '1', text: 'Room 1', color: '#aaa' }, + resourceIndex: 'roomId', + path: [{ + id: '1', + text: 'Room 1', + color: '#aaa', + resourceIndex: 'roomId', + data: { id: '1', text: 'Room 1', color: '#aaa' }, + }], + leafCount: 1, + children: [], + }, + { + key: 'roomId_2', + id: '2', + text: 'Room 2', + color: '#ccc', + data: { id: '2', text: 'Room 2', color: '#ccc' }, + resourceIndex: 'roomId', + path: [{ + id: '2', + text: 'Room 2', + color: '#ccc', + resourceIndex: 'roomId', + data: { id: '2', text: 'Room 2', color: '#ccc' }, + }], + leafCount: 1, + children: [], + }, + ]); + }); + + it('should pass through full resourceData for resourceCellTemplate', () => { + const tree = [{ + ...node('1', 'John Heart', 'employeeID', [], '#aaa'), + resourceData: { + id: 1, + text: 'John Heart', + color: '#aaa', + age: 27, + avatar: '19.png', + discipline: 'ABS, Fitball, StepFit', + }, + }]; + + const result = buildGroupPanelTree(tree); + + expect(result[0].data).toEqual({ + id: 1, + text: 'John Heart', + color: '#aaa', + age: 27, + avatar: '19.png', + discipline: 'ABS, Fitball, StepFit', + }); + }); + + it('should preserve object resource ids in group panel data', () => { + const objectId = { _value: 'guid-1' }; + const tree = [{ + ...node(objectId, 'Owner one', 'ownerId', [], 'rgb(255, 0, 0)'), + resourceData: { + id: objectId, + text: 'Owner one', + color: 'rgb(255, 0, 0)', + }, + }]; + + const result = buildGroupPanelTree(tree); + + expect(result[0].id).toBe(objectId); + expect(result[0].data.id).toBe(objectId); + expect(result[0].key).toBe('ownerId_{"_value":"guid-1"}'); + }); + + it('should omit color from data when it is undefined', () => { + const tree = [ + node('1', 'John', 'ownerId'), + ]; + + const result = buildGroupPanelTree(tree); + + expect(result[0].data).toEqual({ id: '1', text: 'John' }); + expect(result[0].data).not.toHaveProperty('color'); + }); + + it('should compute leafCount for a uniform-depth tree from real descendant counts', () => { + const tree = [ + node('A', 'Room A', 'roomId', [ + node('1', 'John', 'ownerId'), + node('2', 'Jane', 'ownerId'), + ]), + node('B', 'Room B', 'roomId', [ + node('1', 'John', 'ownerId'), + ]), + ]; + + const result = buildGroupPanelTree(tree); + + expect(result[0].leafCount).toBe(2); + expect(result[1].leafCount).toBe(1); + }); + + it('should give every node a unique key even when the same id repeats under different parents', () => { + const tree = [ + node('A', 'Room A', 'roomId', [node('1', 'John', 'ownerId')]), + node('B', 'Room B', 'roomId', [node('1', 'John', 'ownerId')]), + ]; + + const result = buildGroupPanelTree(tree); + const childKeys = result.map((parent) => parent.children[0].key); + + expect(new Set(childKeys).size).toBe(2); + }); + + it('should treat a childless node as its own leaf (leafCount 1) for non-uniform depth', () => { + const tree = [ + node('A', 'Building A', 'buildingId', [ + node('1', 'Room A1', 'roomId'), + node('2', 'Room A2', 'roomId'), + ]), + node('B', 'Building B', 'buildingId', []), + ]; + + const result = buildGroupPanelTree(tree); + + expect(result[0].leafCount).toBe(2); + expect(result[1].leafCount).toBe(1); + }); + + it('should annotate the root-to-cell path for every node', () => { + const tree = [ + node('A', 'Building A', 'buildingId', [ + node('1', 'Room A1', 'roomId', [ + node('x', 'Desk x', 'deskId'), + ]), + ]), + ]; + + const [building] = buildGroupPanelTree(tree); + const [room] = building.children; + const [desk] = room.children; + + expect(building.path).toEqual([ + { + id: 'A', text: 'Building A', color: undefined, resourceIndex: 'buildingId', data: { id: 'A', text: 'Building A' }, + }, + ]); + expect(desk.path.map((item) => item.text)).toEqual(['Building A', 'Room A1', 'Desk x']); + expect(desk.path[desk.path.length - 1].id).toBe('x'); + }); + + it('should not share path items between branches with equal ids', () => { + const tree = [ + node('A', 'Building A', 'buildingId', [node('1', 'Room 1', 'roomId')]), + node('B', 'Building B', 'buildingId', [node('1', 'Room 1', 'roomId')]), + ]; + + const result = buildGroupPanelTree(tree); + + expect(result[0].children[0].path.map((item) => item.text)).toEqual(['Building A', 'Room 1']); + expect(result[1].children[0].path.map((item) => item.text)).toEqual(['Building B', 'Room 1']); + }); + }); + + describe('getGroupPanelTreeDepth', () => { + it('should return 0 for an empty tree', () => { + expect(getGroupPanelTreeDepth([])).toBe(0); + }); + + it('should return 1 for a flat single-level tree', () => { + const tree = buildGroupPanelTree([node('1', 'Room 1', 'roomId')]); + + expect(getGroupPanelTreeDepth(tree)).toBe(1); + }); + + it('should return the depth of the deepest branch for a non-uniform tree', () => { + const tree = buildGroupPanelTree([ + node('A', 'Building A', 'buildingId', [ + node('1', 'Room A1', 'roomId', [ + node('x', 'Desk x', 'deskId'), + ]), + node('2', 'Room A2', 'roomId'), + ]), + node('B', 'Building B', 'buildingId'), + ]); + + expect(getGroupPanelTreeDepth(tree)).toBe(3); + }); + }); + + describe('flattenGroupPanelTreeToRows', () => { + it('should compute colSpan from leafCount and fill missing depth with rowSpan for a shallow leaf', () => { + const tree = buildGroupPanelTree([ + node('A', 'Building A', 'buildingId', [ + node('1', 'Room A1', 'roomId'), + node('2', 'Room A2', 'roomId'), + ]), + node('B', 'Building B', 'buildingId'), + ]); + const maxDepth = getGroupPanelTreeDepth(tree); + + const rows = flattenGroupPanelTreeToRows(tree, maxDepth, 3); + + expect(maxDepth).toBe(2); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual([ + expect.objectContaining({ id: 'A', colSpan: 2 * 3 }), + expect.objectContaining({ id: 'B', colSpan: 1 * 3, rowSpan: 2 }), + ]); + expect(rows[1]).toEqual([ + expect.objectContaining({ id: '1', colSpan: 1 * 3 }), + expect.objectContaining({ id: '2', colSpan: 1 * 3 }), + ]); + }); + + it('should mark cells that do not reach the last column of the table', () => { + const tree = buildGroupPanelTree([ + node('A', 'Building A', 'buildingId', [ + node('1', 'Room A1', 'roomId'), + node('2', 'Room A2', 'roomId'), + ]), + node('B', 'Building B', 'buildingId', [ + node('3', 'Room B1', 'roomId'), + ]), + node('lobby', 'Lobby', 'buildingId'), + ]); + + const rows = flattenGroupPanelTreeToRows(tree, 2, 1); + + expect(rows[0].map(({ text, isLastColumn }) => [text, isLastColumn])).toEqual([ + ['Building A', false], + ['Building B', false], + ['Lobby', true], + ]); + // Room B1 ends the last row, but the Lobby cell spans the columns to its right + expect(rows[1].map(({ text, isLastColumn }) => [text, isLastColumn])).toEqual([ + ['Room A1', false], + ['Room A2', false], + ['Room B1', false], + ]); + }); + + it('should keep isLeaf and path on the flattened header rows', () => { + const tree = buildGroupPanelTree([ + node('A', 'Building A', 'buildingId', [ + node('1', 'Room A1', 'roomId'), + ]), + ]); + + const rows = flattenGroupPanelTreeToRows(tree, 2, 1); + + expect(rows[0][0]).toEqual(expect.objectContaining({ + isLeaf: false, + path: [expect.objectContaining({ text: 'Building A' })], + })); + expect(rows[1][0]).toEqual(expect.objectContaining({ + isLeaf: true, + path: [ + expect.objectContaining({ text: 'Building A' }), + expect.objectContaining({ text: 'Room A1' }), + ], + })); + }); + + it('should handle 3-level non-uniform depth (shallow leaf spans the remaining 2 rows)', () => { + const tree = buildGroupPanelTree([ + node('X', 'Region X', 'regionId', [ + node('X1', 'Building X1', 'buildingId', [ + node('a', 'Room a', 'roomId'), + node('b', 'Room b', 'roomId'), + ]), + node('X2', 'Building X2', 'buildingId'), + ]), + ]); + const maxDepth = getGroupPanelTreeDepth(tree); + + const rows = flattenGroupPanelTreeToRows(tree, maxDepth, 1); + + expect(maxDepth).toBe(3); + expect(rows[0]).toEqual([ + expect.objectContaining({ id: 'X', colSpan: 3 }), + ]); + expect(rows[1]).toEqual([ + expect.objectContaining({ id: 'X1', colSpan: 2 }), + expect.objectContaining({ id: 'X2', colSpan: 1, rowSpan: 2 }), + ]); + expect(rows[2]).toEqual([ + expect.objectContaining({ id: 'a', colSpan: 1 }), + expect.objectContaining({ id: 'b', colSpan: 1 }), + ]); + }); + }); + + describe('flattenGroupPanelTreeToLeafRows', () => { + it('should produce one row per leaf with cells along the root-to-leaf path', () => { + const tree = buildGroupPanelTree([ + node('A', 'Building A', 'buildingId', [ + node('1', 'Room A1', 'roomId'), + node('2', 'Room A2', 'roomId'), + ]), + node('B', 'Building B', 'buildingId'), + ]); + + expect(flattenGroupPanelTreeToLeafRows(tree, 1)).toEqual([ + [ + expect.objectContaining({ key: 'buildingId_A', text: 'Building A' }), + expect.objectContaining({ key: 'buildingId_A_roomId_1', text: 'Room A1' }), + ], + [ + expect.objectContaining({ key: 'buildingId_A', text: 'Building A' }), + expect.objectContaining({ key: 'buildingId_A_roomId_2', text: 'Room A2' }), + ], + [ + expect.objectContaining({ key: 'buildingId_B', text: 'Building B' }), + ], + ]); + }); + + it('should produce one row per top-level leaf for a flat tree', () => { + const tree = buildGroupPanelTree([ + node('1', 'Room 1', 'roomId'), + node('2', 'Room 2', 'roomId'), + ]); + + expect(flattenGroupPanelTreeToLeafRows(tree, 3)).toEqual([ + [expect.objectContaining({ key: 'roomId_1', text: 'Room 1', colSpan: 3 })], + [expect.objectContaining({ key: 'roomId_2', text: 'Room 2', colSpan: 3 })], + ]); + }); + + it('should keep isLeaf and path on every cell of a leaf row', () => { + const tree = buildGroupPanelTree([ + node('A', 'Building A', 'buildingId', [ + node('1', 'Room A1', 'roomId'), + ]), + ]); + + const [row] = flattenGroupPanelTreeToLeafRows(tree, 1); + + expect(row.map(({ isLeaf }) => isLeaf)).toEqual([false, true]); + expect(row[1].path.map((item) => item.text)).toEqual(['Building A', 'Room A1']); + }); + }); + + describe('getResourceCellTemplateData', () => { + it('should build the template model from a group panel tree node', () => { + const tree = buildGroupPanelTree([ + node('A', 'Building A', 'buildingId', [ + node('1', 'Room A1', 'roomId', [], '#aaa'), + ]), + ]); + const [room] = tree[0].children; + + expect(getResourceCellTemplateData(room)).toEqual({ + data: { id: '1', text: 'Room A1', color: '#aaa' }, + id: '1', + text: 'Room A1', + color: '#aaa', + resourceIndex: 'roomId', + level: 1, + isLeaf: true, + path: room.path, + }); + }); + + it.each([ + ['is not provided', undefined], + ['is empty', []], + ])('should fall back to a single-level model when the path %s', (_, path) => { + expect(getResourceCellTemplateData({ + id: 1, + text: 'Room 1', + color: '#aaa', + data: { id: 1, text: 'Room 1' }, + resourceIndex: 'roomId', + path, + })).toEqual({ + data: { id: 1, text: 'Room 1' }, + id: 1, + text: 'Room 1', + color: '#aaa', + resourceIndex: 'roomId', + level: 0, + isLeaf: true, + path: [{ + id: 1, + text: 'Room 1', + color: '#aaa', + resourceIndex: 'roomId', + data: { id: 1, text: 'Room 1' }, + }], + }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/base.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/base.ts index 5b81793a51af..8833292877ce 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/utils/base.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/base.ts @@ -1,9 +1,8 @@ import dateLocalization from '@js/common/core/localization/date'; import dateUtils from '@js/core/utils/date'; -import { isDefined, isObject } from '@js/core/utils/type'; +import { isDefined } from '@js/core/utils/type'; import { dateUtilsTs } from '@ts/core/utils/date'; -import { VERTICAL_GROUP_COUNT_CLASSES } from '../../classes'; import { HORIZONTAL_GROUP_ORIENTATION, VERTICAL_GROUP_ORIENTATION, } from '../../constants'; @@ -20,11 +19,16 @@ import type { ViewDataProviderType, ViewType, } from '../../types'; -import type { ResourceLoader } from '../../utils/loader/resource_loader'; -import type { ResourceId } from '../../utils/loader/types'; import { VIEWS } from '../../utils/options/constants_view'; +import type { GroupNode } from '../../utils/resource_manager/types'; import timeZoneUtils from '../../utils_time_zone'; import type { TimeZoneCalculator } from '../timezone_calculator'; +import { + buildGroupPanelTree, + flattenGroupPanelTreeToLeafRows, + flattenGroupPanelTreeToRows, + getGroupPanelTreeDepth, +} from './group_panel_tree'; const toMs = dateUtils.dateToMilliseconds; const DAY_HOURS = 24; @@ -94,19 +98,6 @@ export const getOverflowIndicatorColor = (color: string, colors: string[]): stri : undefined ); -export const getVerticalGroupCountClass = (groups: unknown[]): string | undefined => { - switch (groups?.length) { - case 1: - return VERTICAL_GROUP_COUNT_CLASSES[0]; - case 2: - return VERTICAL_GROUP_COUNT_CLASSES[1]; - case 3: - return VERTICAL_GROUP_COUNT_CLASSES[2]; - default: - return undefined; - } -}; - export const setOptionHour = (date: Date, optionHour: number): Date => { const nextDate = new Date(date); @@ -449,50 +440,46 @@ export const extendGroupItemsForGroupingByDate = ( ] as GroupRenderItem[]; }), []) as GroupRenderItem[][]; -const stringifyId = (id: ResourceId): string => (isObject(id) - ? JSON.stringify(id) - : String(id)); - export const getGroupPanelData = ( - groupResources: ResourceLoader[], + groupsTree: GroupNode[], columnCountPerGroup: number, groupByDate: boolean, baseColSpan: number, + hasHierarchy = false, ): GroupPanelData => { - let repeatCount = 1; - let groupPanelItems = groupResources - .map((group) => { - const result = [] as GroupRenderItem[]; - const { - resourceName, resourceIndex, items, data, - } = group; - - for (let i = 0; i < repeatCount; i += 1) { - result.push(...items.map(({ id, text, color }, index) => ({ - id, - text, - color, - key: `${i}_${resourceIndex}_${stringifyId(id)}`, - resourceName, - data: data?.[index], - }) as GroupRenderItem)); - } - - repeatCount *= items.length; - return result; - }) - .filter((group) => group.length); + const groupTree = buildGroupPanelTree(groupsTree); + const maxDepth = getGroupPanelTreeDepth(groupTree); + let groupPanelItems = flattenGroupPanelTreeToRows(groupTree, maxDepth, baseColSpan); if (groupByDate) { groupPanelItems = extendGroupItemsForGroupingByDate(groupPanelItems, columnCountPerGroup); } return { + groupTree, groupPanelItems, + maxDepth, baseColSpan, + columnCountPerGroup, + hasHierarchy, }; }; +export const getTimelineGroupPanelRows = ( + groupPanelData: GroupPanelData, + groupByDate: boolean, +): GroupRenderItem[][] => { + let rows = groupPanelData.hasHierarchy + ? flattenGroupPanelTreeToLeafRows(groupPanelData.groupTree, groupPanelData.baseColSpan) + : groupPanelData.groupPanelItems; + + if (groupByDate) { + rows = extendGroupItemsForGroupingByDate(rows, groupPanelData.columnCountPerGroup); + } + + return rows; +}; + export const splitNumber = (value: number, splitValue: number): number[] => Array.from( { length: Math.ceil(value / splitValue) }, (_, index) => Math.min(value - (splitValue * index), splitValue), diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/group_panel_tree.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/group_panel_tree.ts new file mode 100644 index 000000000000..b37407800480 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/group_panel_tree.ts @@ -0,0 +1,169 @@ +import { getKeyHash } from '@js/core/utils/common'; + +import type { + GroupHeaderHierarchy, GroupHeaderPathItem, GroupItem, GroupPanelTreeNode, GroupRenderItem, +} from '../../types'; +import type { ResourceId } from '../../utils/loader/types'; +import type { GroupNode } from '../../utils/resource_manager/types'; +import type { ResourceCellTemplateData } from '../components/types'; + +export const stringifyId = (id: ResourceId): string => String(getKeyHash(id)); + +const buildGroupPanelData = (node: GroupNode): GroupItem => { + if (node.resourceData) { + return node.resourceData as GroupItem; + } + + const data: GroupItem = { id: node.id, text: node.resourceText }; + + if (node.color !== undefined) { + data.color = node.color; + } + + return data; +}; + +const buildGroupPanelNode = ( + node: GroupNode, + parentKey: string, + parentPath: GroupHeaderPathItem[], +): GroupPanelTreeNode => { + const key = `${parentKey}${node.resourceIndex}_${stringifyId(node.id)}`; + const cell: GroupHeaderPathItem = { + id: node.id, + text: node.resourceText, + color: node.color, + resourceIndex: node.resourceIndex, + data: buildGroupPanelData(node), + }; + const path = [...parentPath, cell]; + const children = node.children.map( + (child) => buildGroupPanelNode(child, `${key}_`, path), + ); + const leafCount = children.length === 0 + ? 1 + : children.reduce((sum, child) => sum + child.leafCount, 0); + + return { + key, + ...cell, + path, + leafCount, + children, + }; +}; + +export const buildGroupPanelTree = ( + groupsTree: GroupNode[], +): GroupPanelTreeNode[] => groupsTree.map( + (node) => buildGroupPanelNode(node, '', []), +); + +export const getGroupPanelTreeDepth = (tree: GroupPanelTreeNode[]): number => { + if (tree.length === 0) { + return 0; + } + + return 1 + Math.max(...tree.map((node) => getGroupPanelTreeDepth(node.children))); +}; + +export const flattenGroupPanelTreeToRows = ( + tree: GroupPanelTreeNode[], + maxDepth: number, + baseColSpan: number, +): GroupRenderItem[][] => { + const rows: GroupRenderItem[][] = Array.from({ length: maxDepth }, () => []); + + const walk = (node: GroupPanelTreeNode, depth: number, isLastColumn: boolean): void => { + const isLeaf = node.children.length === 0; + const isShallowLeaf = isLeaf && depth < maxDepth - 1; + + rows[depth].push({ + id: node.id, + text: node.text, + color: node.color, + key: node.key, + resourceIndex: node.resourceIndex, + data: node.data, + isLeaf, + isLastColumn, + path: node.path, + colSpan: node.leafCount * baseColSpan, + ...(isShallowLeaf ? { rowSpan: maxDepth - depth } : {}), + }); + + node.children.forEach((child, index) => walk( + child, + depth + 1, + isLastColumn && index === node.children.length - 1, + )); + }; + + tree.forEach((node, index) => walk(node, 0, index === tree.length - 1)); + + return rows; +}; + +const toGroupRenderItem = ( + node: GroupPanelTreeNode, + baseColSpan: number, +): GroupRenderItem => ({ + id: node.id, + text: node.text, + color: node.color, + key: node.key, + resourceIndex: node.resourceIndex, + data: node.data, + isLeaf: node.children.length === 0, + path: node.path, + colSpan: baseColSpan, +}); + +export const flattenGroupPanelTreeToLeafRows = ( + tree: GroupPanelTreeNode[], + baseColSpan: number, +): GroupRenderItem[][] => { + const rows: GroupRenderItem[][] = []; + + const walk = (node: GroupPanelTreeNode, path: GroupRenderItem[]): void => { + const currentPath = [...path, toGroupRenderItem(node, baseColSpan)]; + + if (node.children.length === 0) { + rows.push(currentPath); + return; + } + + node.children.forEach((child) => walk(child, currentPath)); + }; + + tree.forEach((node) => walk(node, [])); + + return rows; +}; + +interface GroupHeaderCellInfo extends Partial { + id: ResourceId; + text?: string; + color?: string; + resourceIndex?: string; + data: GroupItem; +} + +export const getResourceCellTemplateData = ({ + id, text, color, data, resourceIndex = '', isLeaf = true, path, +}: GroupHeaderCellInfo): ResourceCellTemplateData => { + const cellPath = path?.length ? path : [{ + id, text, color, resourceIndex, data, + }]; + + return { + data, + id, + text, + color, + resourceIndex, + isLeaf, + path: cellPath, + level: cellPath.length - 1, + }; +}; diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/index.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/index.ts index e1a2585159ad..07515d74ea3d 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/utils/index.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/index.ts @@ -57,11 +57,11 @@ export { getSkippedHoursInRange, getStartViewDateTimeOffset, getStartViewDateWithoutDST, + getTimelineGroupPanelRows, getToday, getTotalCellCountByCompleteData, getTotalRowCountByCompleteData, getValidCellDateForLocalTimeFormat, - getVerticalGroupCountClass, getViewStartByOptions, isAppointmentTakesAllDay, isDateAndTimeView, @@ -82,6 +82,14 @@ export { formatWeekday, formatWeekdayAndDay, } from './format_weekday'; +export { + buildGroupPanelTree, + flattenGroupPanelTreeToLeafRows, + flattenGroupPanelTreeToRows, + getGroupPanelTreeDepth, + getResourceCellTemplateData, + stringifyId, +} from './group_panel_tree'; export const agendaUtils = { calculateEndViewDate, diff --git a/packages/devextreme/js/__internal/scheduler/types.ts b/packages/devextreme/js/__internal/scheduler/types.ts index 11d0bcbf465b..8f5108b2019a 100644 --- a/packages/devextreme/js/__internal/scheduler/types.ts +++ b/packages/devextreme/js/__internal/scheduler/types.ts @@ -4,6 +4,7 @@ import type { Appointment, Properties } from '@js/ui/scheduler'; import type { Component } from '@ts/core/widget/component'; import type { ResourceLoader } from './utils/loader/resource_loader'; +import type { ResourceId } from './utils/loader/types'; import type { GroupLeaf, GroupValues, RawGroupValues } from './utils/resource_manager/types'; import type { AppointmentItemViewModel } from './view_model/types'; @@ -57,7 +58,7 @@ export type GetDateForHeaderText = ( ) => Date; export interface GroupItem { - id: number | string; + id: ResourceId; text?: string; color?: string; } @@ -129,13 +130,33 @@ export interface ViewOptions { endDayHour: number; } -export interface GroupRenderItem extends GroupItem { - key: string; - resourceName: string; +export interface GroupHeaderPathItem { + id: ResourceId; + text?: string; + color?: string; + resourceIndex: string; data: GroupItem; +} + +export interface GroupHeaderHierarchy { + isLeaf: boolean; + path: GroupHeaderPathItem[]; +} + +export interface GroupRenderItem extends GroupHeaderPathItem, GroupHeaderHierarchy { + key: string; colSpan?: number; + rowSpan?: number; isFirstGroupCell?: boolean; isLastGroupCell?: boolean; + isLastColumn?: boolean; +} + +export interface GroupPanelTreeNode extends GroupHeaderPathItem { + key: string; + path: GroupHeaderPathItem[]; + leafCount: number; + children: GroupPanelTreeNode[]; } export interface CellPositionData { @@ -223,8 +244,12 @@ export interface DateHeaderData { } export interface GroupPanelData { + groupTree: GroupPanelTreeNode[]; groupPanelItems: GroupRenderItem[][]; + maxDepth: number; baseColSpan: number; + columnCountPerGroup: number; + hasHierarchy: boolean; } export interface ViewDataBase { diff --git a/packages/devextreme/js/__internal/scheduler/utils/data_accessor/resource_data_accessor.test.ts b/packages/devextreme/js/__internal/scheduler/utils/data_accessor/resource_data_accessor.test.ts index 529da5a22104..83c679da41e7 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/data_accessor/resource_data_accessor.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/data_accessor/resource_data_accessor.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, } from '@jest/globals'; +import type { RawResourceData } from '../loader/types'; import { ResourceDataAccessor } from './resource_data_accessor'; describe('ResourceDataAccessor', () => { @@ -56,4 +57,45 @@ describe('ResourceDataAccessor', () => { expect(customResource.mainColor).toBe('color'); }); }); + + describe('function valueExpr/displayExpr', () => { + const customResource: any = { complex: { item: { guid: '0' } }, name: 'Room 1' }; + const accessor = new ResourceDataAccessor({ + fieldExpr: 'roomId', + dataSource: [], + valueExpr: (resource: any) => resource.complex.item.guid, + displayExpr: (resource: any) => resource.name, + label: 'Room', + }); + + it('should get fields via a function expression', () => { + expect(accessor.get('id', customResource)).toBe(customResource.complex.item.guid); + expect(accessor.get('text', customResource)).toBe(customResource.name); + }); + + it('should not throw when setting a field defined by a function expression', () => { + expect(() => accessor.set('id', customResource, 10)).not.toThrow(); + expect(() => accessor.set('text', customResource, 'text')).not.toThrow(); + }); + }); + + describe('parentId', () => { + const hierarchicalResource: RawResourceData = { id: 11, text: 'Room 11', parentId: 'board' }; + const accessor = new ResourceDataAccessor({ + fieldExpr: 'roomId', + dataSource: [], + parentIdExpr: 'parentId', + label: 'Room', + }); + + it('should get parentId with configured parentIdExpr', () => { + expect(accessor.get('parentId', hierarchicalResource)).toBe('board'); + }); + + it('should set parentId with configured parentIdExpr', () => { + accessor.set('parentId', hierarchicalResource, 'open'); + + expect(hierarchicalResource.parentId).toBe('open'); + }); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/utils/data_accessor/resource_data_accessor.ts b/packages/devextreme/js/__internal/scheduler/utils/data_accessor/resource_data_accessor.ts index 81a28fe04db2..6bcd5785acac 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/data_accessor/resource_data_accessor.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/data_accessor/resource_data_accessor.ts @@ -11,6 +11,8 @@ export class ResourceDataAccessor extends DataAccessor = { idExpr: this.idExpr, textExpr: this.textExpr, colorExpr: this.colorExpr, - }); + }; + + if (this.parentIdExpr) { + expressions.parentIdExpr = this.parentIdExpr; + } + + this.updateExpressions(expressions); } - public updateExpression(field: string, expr: string | undefined): void { + public updateExpression(field: string, expr: string | Function | undefined): void { const name = field.replace('Expr', ''); if (!expr) { @@ -37,6 +47,13 @@ export class ResourceDataAccessor extends DataAccessor; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.setter[name]; + return; + } + this.getter[name] = compileGetter(expr) as DataAccessorGetter; this.setter[name] = compileSetter(expr) as DataAccessorSetter; } diff --git a/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.test.ts b/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.test.ts index ab5215c4919e..9589f0a76a3e 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.test.ts @@ -54,4 +54,104 @@ describe('resource loader', () => { expect(loader.isLoaded()).toBe(true); }); }); + + describe('Hierarchy', () => { + const roomData = [ + { + id: 'board', text: 'Board rooms', color: '#111', parentId: null, + }, + { + id: 'open', text: 'Open spaces', color: '#222', parentId: null, + }, + { + id: 11, text: 'Room 11', color: '#333', parentId: 'board', + }, + { + id: 12, text: 'Room 12', color: '#444', parentId: 'board', + }, + { + id: 21, text: 'Room 21', color: '#555', parentId: 'open', + }, + ]; + const getHierarchyConfig = (dataSource) => ({ + fieldExpr: 'roomId', + parentIdExpr: 'parentId', + dataSource, + label: 'Room', + }); + + it('should build hierarchy tree and leaf items from flat dataSource', async () => { + const loader = new ResourceLoader(getHierarchyConfig(roomData)); + + await loader.load(); + + expect(loader.hasHierarchy).toBe(true); + expect(loader.items).toHaveLength(5); + expect(loader.leafItems.map((item) => item.id)).toEqual([11, 12, 21]); + expect(loader.hierarchyTree.map((node) => node.data.id)).toEqual(['board', 'open']); + expect(loader.hierarchyTree[0].children.map((node) => node.data.id)).toEqual([11, 12]); + }); + + it('should keep flat behavior when parentIdExpr is not configured', async () => { + const loader = new ResourceLoader({ + fieldExpr: 'roomId', + dataSource: roomData, + label: 'Room', + }); + + await loader.load(); + + expect(loader.hasHierarchy).toBe(false); + expect(loader.hierarchyTree).toEqual([]); + expect(loader.leafItems).toEqual(loader.items); + expect(loader.leafData).toEqual(loader.data); + }); + + it('should collect raw leaf items into leafData', async () => { + const loader = new ResourceLoader(getHierarchyConfig(roomData)); + + await loader.load(); + + expect(loader.leafData).toEqual([roomData[2], roomData[3], roomData[4]]); + }); + + it('should collect raw leaf items from a given array', () => { + const loader = new ResourceLoader(getHierarchyConfig(roomData)); + + const leafData = loader.collectLeafData(roomData); + + expect(leafData).toEqual([roomData[2], roomData[3], roomData[4]]); + }); + + it('should keep the user fields and expressions usable in leafData', async () => { + const rawData = [ + { key: 'board', name: 'Board rooms', floor: 1 }, + { + key: 11, name: 'Room 11', parent: 'board', floor: 2, + }, + ]; + const loader = new ResourceLoader({ + fieldExpr: 'roomId', + parentIdExpr: 'parent', + valueExpr: 'key', + displayExpr: 'name', + dataSource: rawData, + }); + + await loader.load(); + + expect(loader.leafData).toEqual([rawData[1]]); + }); + + it('should clear hierarchy tree and leaf items on dispose', async () => { + const loader = new ResourceLoader(getHierarchyConfig(roomData)); + + await loader.load(); + loader.dispose(); + + expect(loader.hierarchyTree).toEqual([]); + expect(loader.leafItems).toEqual([]); + expect(loader.leafData).toEqual([]); + }); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.ts b/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.ts index 910f50415fb4..07c4330e5385 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/loader/resource_loader.ts @@ -4,6 +4,11 @@ import { type ResourceIdAccessor, } from '../data_accessor/appointment_resource_data_accessor'; import { ResourceDataAccessor } from '../data_accessor/resource_data_accessor'; +import type { ResourceHierarchyNode } from '../resource_manager/hierarchy_tree_utils'; +import { + buildHierarchyTree, + collectHierarchyLeaves, +} from '../resource_manager/hierarchy_tree_utils'; import { Loader } from './loader'; import type { RawResourceData, @@ -11,6 +16,16 @@ import type { ResourceData, } from './types'; +const filterLeafData = ( + data: RawResourceData[], + items: ResourceData[], + leafItems: ResourceData[], +): RawResourceData[] => { + const leaves = new Set(leafItems); + + return data.filter((_, index) => leaves.has(items[index])); +}; + export class ResourceLoader extends Loader { public idsGetter: ResourceIdAccessor['idsGetter']; @@ -28,6 +43,14 @@ export class ResourceLoader extends Loader { public icon?: string; + public hasHierarchy: boolean; + + public hierarchyTree: ResourceHierarchyNode[] = []; + + public leafItems: ResourceData[] = []; + + public leafData: RawResourceData[] = []; + constructor(config: ResourceConfig) { super(config, { pageSize: 0 }); const accessor = getAppointmentResourceAccessor(config); @@ -40,22 +63,64 @@ export class ResourceLoader extends Loader { this.resourceIndex = String(getResourceIndex(config)); this.resourceName = config.label; this.icon = config.icon; + this.hasHierarchy = Boolean(config.parentIdExpr); this.onInit(); } protected onLoadTransform(items: RawResourceData[]): ResourceData[] { - return items.map((item) => ({ - id: this.dataAccessor.get('id', item), - text: this.dataAccessor.get('text', item), - color: this.dataAccessor.get('color', item), - })); + return items.map((item) => { + const resource: ResourceData = { + id: this.dataAccessor.get('id', item), + text: this.dataAccessor.get('text', item), + color: this.dataAccessor.get('color', item), + }; + + if (this.hasHierarchy) { + resource.parentId = this.dataAccessor.get('parentId', item) ?? null; + } + + return resource; + }); } protected applyChanges(items: RawResourceData[]): void { + const hasChanged = Boolean(items) && items !== this.data; + super.applyChanges(items); + + if (hasChanged) { + this.rebuildHierarchy(); + } + } + + protected rebuildHierarchy(): void { + if (!this.hasHierarchy) { + this.hierarchyTree = []; + this.leafItems = this.items; + this.leafData = this.data; + return; + } + + this.hierarchyTree = buildHierarchyTree(this.items); + this.leafItems = collectHierarchyLeaves(this.hierarchyTree); + this.leafData = filterLeafData(this.data, this.items, this.leafItems); + } + + public collectLeafData(data: RawResourceData[]): RawResourceData[] { + const items = this.onLoadTransform(data); + const leafItems = collectHierarchyLeaves(buildHierarchyTree(items)); + + return filterLeafData(data, items, leafItems); } protected onLoadError(): void {} protected onChange(): void {} + + public dispose(): void { + super.dispose(); + this.hierarchyTree = []; + this.leafItems = []; + this.leafData = []; + } } diff --git a/packages/devextreme/js/__internal/scheduler/utils/loader/types.ts b/packages/devextreme/js/__internal/scheduler/utils/loader/types.ts index 92c7317dc07f..4a4b9e67677c 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/loader/types.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/loader/types.ts @@ -6,17 +6,20 @@ export type AppointmentResourceConfig = ResourceId | ResourceId[]; type ResourcesConfig = Required['resources']; export type ResourceConfig = ResourcesConfig[number] & { - field?: string; // old notation of fieldExpr + field?: string; + parentIdExpr?: string; }; -export type RawResourceData = Record & { +export type RawResourceData = Record & { id?: ResourceId; text?: string; color?: string; + parentId?: ResourceId | null; }; export interface ResourceData extends Record { id: ResourceId; text: string; color: string; + parentId?: ResourceId | null; } diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/agenda_group_utils.test.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/agenda_group_utils.test.ts index e2eb52c0e553..61844470e51a 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/agenda_group_utils.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/agenda_group_utils.test.ts @@ -5,24 +5,50 @@ import { import { getResourceManagerMock } from '../../__mock__/resource_manager.mock'; import { reduceResourcesTree } from './agenda_group_utils'; +const omitResourceData = (value: T): T => { + const strip = (obj: unknown): unknown => { + if (Array.isArray(obj)) { + return obj.map(strip); + } + + if (obj && typeof obj === 'object') { + const { resourceData, ...rest } = obj as Record; + + return Object.fromEntries( + Object.entries(rest).map(([key, val]) => [key, strip(val)]), + ); + } + + return obj; + }; + + return strip(value) as T; +}; + describe('agenda group utils', () => { describe('reduceResourcesTree', () => { it('should reduce tree by appointments resources', async () => { const manager = getResourceManagerMock(); await manager.loadGroupResources(['roomId', 'nested.priorityId']); - expect(reduceResourcesTree(manager.resourceById, manager.groupsTree, [ + expect(omitResourceData(reduceResourcesTree(manager.resourceById, manager.groupsTree, [ { itemData: { roomId: 0, nested: { priorityId: [1, 2] } } }, { itemData: { roomId: 1, nested: { priorityId: 2 } } }, - ] as any)).toEqual([ + ] as any))).toEqual([ { + id: 0, + color: '#aaa', children: [ { + id: 1, + color: '#1e90ff', children: [], grouped: { 'nested.priorityId': 1, roomId: 0 }, resourceIndex: 'nested.priorityId', resourceText: 'Low Priority', }, { + id: 2, + color: '#ff9747', children: [], grouped: { 'nested.priorityId': 2, roomId: 0 }, resourceIndex: 'nested.priorityId', @@ -34,14 +60,20 @@ describe('agenda group utils', () => { resourceText: 'Room 1', }, { + id: 1, + color: '#ccc', children: [ { + id: 1, + color: '#1e90ff', children: [], grouped: { 'nested.priorityId': 1, roomId: 1 }, resourceIndex: 'nested.priorityId', resourceText: 'Low Priority', }, { + id: 2, + color: '#ff9747', children: [], grouped: { 'nested.priorityId': 2, roomId: 1 }, resourceIndex: 'nested.priorityId', diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/appointment_groups_utils.test.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/appointment_groups_utils.test.ts index 205d39e48465..7fa526e55507 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/appointment_groups_utils.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/appointment_groups_utils.test.ts @@ -3,7 +3,8 @@ import { } from '@jest/globals'; import { complexIdResourceMock, - getResourceManagerMock, resourceIndexesMock, resourceItemsByIdMock, + getResourceManagerMock, hierarchicalRoomsConfigMock, + resourceIndexesMock, resourceItemsByIdMock, } from '@ts/scheduler/__mock__/resource_manager.mock'; import { @@ -15,6 +16,7 @@ import { groupAppointmentsByGroupLeafs, setAppointmentGroupValues, } from './appointment_groups_utils'; +import type { GroupLeaf } from './types'; describe('appointment groups utils', () => { describe('getResourceItemById', () => { @@ -181,6 +183,43 @@ describe('appointment groups utils', () => { }, manager.groupsLeafs), ).toEqual([2, 5]); }); + + describe('hierarchical resource', () => { + const loadLeafs = async (): Promise => { + const manager = getResourceManagerMock([{ ...hierarchicalRoomsConfigMock }]); + await manager.loadGroupResources(['roomId']); + + return manager.groupsLeafs; + }; + + it.each([ + { title: 'a leaf in the middle of a branch', values: [11], expected: [0] }, + { title: 'a leaf of the last branch', values: [21], expected: [2] }, + { title: 'a leaf at the root level', values: ['solo'], expected: [3] }, + { title: 'leafs of different branches', values: [12, 21], expected: [1, 2] }, + ])('should return leaf group indexes for $title', async ({ values, expected }) => { + expect(getAppointmentGroupIndex({ roomId: values }, await loadLeafs())) + .toEqual(expected); + }); + + it.each([ + { title: 'a parent id', values: ['board'] }, + { title: 'an unknown id', values: [404] }, + ])('should return no group indexes for $title', async ({ values }) => { + expect(getAppointmentGroupIndex({ roomId: values }, await loadLeafs())) + .toEqual([]); + }); + }); + + // Regression: ids are compared by value, so non-primitive ids (valueExpr) must match + it('should return appointment group indexes for complex ids', async () => { + const manager = getResourceManagerMock(complexIdResourceMock); + await manager.loadGroupResources(['ownerId']); + + expect( + getAppointmentGroupIndex({ ownerId: [{ _value: 'guid-2' }] }, manager.groupsLeafs), + ).toEqual([1]); + }); }); describe('groupAppointmentsByGroupLeafs', () => { diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/appointment_groups_utils.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/appointment_groups_utils.ts index 4d77398d1663..5b09bf2595cb 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/appointment_groups_utils.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/appointment_groups_utils.ts @@ -87,17 +87,21 @@ export const setAppointmentGroupValues = >( }); }; +// Note: ids can be non-primitive (see valueExpr), so they need a by-value comparison +const isGroupLeafMatched = ( + leaf: GroupLeaf, + appointmentGroupValues: GroupValues, +): boolean => Object + .entries(leaf.grouped) + .every(([resourceIndex, resourceId]) => appointmentGroupValues[resourceIndex] + ?.some((id) => equalByValue(id, resourceId))); + export const getAppointmentGroupIndex = ( appointmentGroupValues: GroupValues, groupLeafs: GroupLeaf[], ): GroupLeaf['groupIndex'][] => groupLeafs - .filter( - (leaf) => Object - .entries(leaf.grouped) - .every(( - [resourceIndex, resourceId], - ) => appointmentGroupValues[resourceIndex]?.includes(resourceId)), - ).map((leaf) => leaf.groupIndex); + .filter((leaf) => isGroupLeafMatched(leaf, appointmentGroupValues)) + .map((leaf) => leaf.groupIndex); export const groupAppointmentsByGroupLeafs = ( resourceById: Record, @@ -108,15 +112,11 @@ export const groupAppointmentsByGroupLeafs = ( return [appointments]; } + const resources = Object.values(resourceById); + return groupLeafs.map( - (leaf) => appointments.filter((item) => { - const appointmentGroupValues = getAppointmentGroupValues(item, Object.values(resourceById)); - - return Object - .entries(leaf.grouped) - .every(( - [resourceIndex, resourceId], - ) => appointmentGroupValues[resourceIndex]?.includes(resourceId)); - }), + (leaf) => appointments.filter( + (item) => isGroupLeafMatched(leaf, getAppointmentGroupValues(item, resources)), + ), ); }; diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts index ed85fdb1379f..79475e64b57a 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.test.ts @@ -1,30 +1,120 @@ import { - describe, expect, it, + beforeAll, describe, expect, it, } from '@jest/globals'; import { getResourceManagerMock } from '@ts/scheduler/__mock__/resource_manager.mock'; +import { ResourceLoader } from '../loader/resource_loader'; +import type { RawResourceData } from '../loader/types'; import { getAllGroupValues, getGroupTexts, getLeafGroupValues, getResourcesByGroupIndex, groupResources, } from './group_utils'; +import type { GroupLeaf } from './types'; -const groupsLeafs: any = [ - { groupIndex: 0, grouped: { assigneeId: 1, roomId: 3 } }, - { groupIndex: 1, grouped: { assigneeId: 3, roomId: 4 } }, - { groupIndex: 2, grouped: { roomId: 0 } }, - { groupIndex: 3, grouped: { assigneeId: 0, roomId: 0 } }, +const assigneeData: RawResourceData[] = [ + { id: 0, text: 'Samantha Bright', color: '#727bd2' }, + { id: 1, text: 'John Heart', color: '#32c9ed' }, ]; -const resourceById: any = { - assigneeId: { - resourceIndex: 'assigneeId', - items: [{ id: 0, text: 'Samantha Bright' }, { id: 1, text: 'John Heart' }], + +const roomData: RawResourceData[] = [ + { id: 0, text: 'Room 1', color: '#aaa' }, + { id: 1, text: 'Room 2', color: '#ccc' }, +]; + +const roomHierarchyData: RawResourceData[] = [ + { + id: 'board', text: 'Board rooms', color: '#111', parentId: null, + }, + { + id: 'open', text: 'Open spaces', color: '#222', parentId: null, }, - roomId: { - resourceIndex: 'roomId', - items: [{ id: 0, text: 'Room 1' }, { id: 1, text: 'Room 2' }], + { + id: 11, text: 'Room 11', color: '#333', parentId: 'board', }, + { + id: 12, text: 'Room 12', color: '#444', parentId: 'board', + }, + { + id: 21, text: 'Room 21', color: '#555', parentId: 'open', + }, +]; + +const createResourceLoader = async ( + fieldExpr: string, + dataSource: RawResourceData[], + label: string, + parentIdExpr?: string, +): Promise => { + const loader = new ResourceLoader({ + fieldExpr, + dataSource, + label, + parentIdExpr, + }); + + await loader.load(); + + return loader; +}; + +const createHierarchicalRoomResource = (): Promise => createResourceLoader( + 'roomId', + roomHierarchyData, + 'Room', + 'parentId', +); + +const createGroupLeaf = ( + groupIndex: number, + grouped: GroupLeaf['grouped'], +): GroupLeaf => ({ + groupIndex, + grouped, + id: 0, + resourceText: '', + resourceIndex: '', + children: [], +}); + +const omitResourceData = (value: T): T => { + const strip = (obj: unknown): unknown => { + if (Array.isArray(obj)) { + return obj.map(strip); + } + + if (obj && typeof obj === 'object') { + const { resourceData, ...rest } = obj as Record; + + return Object.fromEntries( + Object.entries(rest).map(([key, val]) => [key, strip(val)]), + ); + } + + return obj; + }; + + return strip(value) as T; }; +const groupsLeafs: GroupLeaf[] = [ + createGroupLeaf(0, { assigneeId: 1, roomId: 3 }), + createGroupLeaf(1, { assigneeId: 3, roomId: 4 }), + createGroupLeaf(2, { roomId: 0 }), + createGroupLeaf(3, { assigneeId: 0, roomId: 0 }), +]; + describe('groups utils', () => { + // eslint-disable-next-line @typescript-eslint/init-declarations + let resourceById: Record; + + beforeAll(async () => { + const [assigneeId, roomId] = await Promise.all([ + createResourceLoader('assigneeId', assigneeData, 'Assignee'), + createResourceLoader('roomId', roomData, 'Room'), + ]); + + resourceById = { assigneeId, roomId }; + }); + describe('groupResources', () => { it('should return empty tree for empty groups', () => { expect(groupResources(resourceById, [])).toEqual({ @@ -40,16 +130,27 @@ describe('groups utils', () => { }); }); + it('should return empty tree when groups only contains keys missing from resourceById', () => { + expect(groupResources(resourceById, ['unknownId'])).toEqual({ + groupTree: [], + groupLeafs: [], + }); + }); + it('should group by one group', () => { - expect(groupResources(resourceById, ['roomId'])).toEqual({ + expect(omitResourceData(groupResources(resourceById, ['roomId']))).toEqual({ groupTree: [ { + id: 0, + color: '#aaa', children: [], grouped: { roomId: 0 }, resourceIndex: 'roomId', resourceText: 'Room 1', }, { + id: 1, + color: '#ccc', children: [], grouped: { roomId: 1 }, resourceIndex: 'roomId', @@ -58,6 +159,8 @@ describe('groups utils', () => { ], groupLeafs: [ { + id: 0, + color: '#aaa', children: [], groupIndex: 0, grouped: { roomId: 0 }, @@ -65,6 +168,8 @@ describe('groups utils', () => { resourceText: 'Room 1', }, { + id: 1, + color: '#ccc', children: [], groupIndex: 1, grouped: { roomId: 1 }, @@ -75,18 +180,61 @@ describe('groups utils', () => { }); }); + it('should attach raw resourceData to group nodes', () => { + const { groupTree } = groupResources(resourceById, ['roomId', 'assigneeId']); + + expect(groupTree[0].resourceData).toEqual(roomData[0]); + expect(groupTree[0].children[0].resourceData).toEqual(assigneeData[0]); + expect(groupTree[0].children[1].resourceData).toEqual(assigneeData[1]); + }); + + it('should attach raw resourceData to hierarchical nodes when valueExpr returns new object ids', async () => { + const hierarchyData: RawResourceData[] = [ + { + id: { guid: 'board' }, + text: 'Board rooms', + color: '#111', + parentId: null, + }, + { + id: { guid: 11 }, + text: 'Room 11', + color: '#333', + parentId: { guid: 'board' }, + }, + ]; + const loader = new ResourceLoader({ + fieldExpr: 'roomId', + dataSource: hierarchyData, + label: 'Room', + parentIdExpr: 'parentId', + valueExpr: (item: RawResourceData) => ({ ...(item.id as object) }), + }); + + await loader.load(); + + const { groupTree } = groupResources({ roomId: loader }, ['roomId']); + + expect(groupTree[0].resourceData).toEqual(hierarchyData[0]); + expect(groupTree[0].children[0].resourceData).toEqual(hierarchyData[1]); + }); + it('should ignore missed resources and group by one group', () => { - expect(groupResources({ + expect(omitResourceData(groupResources({ roomId: resourceById.roomId, - }, ['roomId', 'assigneeId'])).toEqual({ + }, ['roomId', 'assigneeId']))).toEqual({ groupTree: [ { + id: 0, + color: '#aaa', children: [], grouped: { roomId: 0 }, resourceIndex: 'roomId', resourceText: 'Room 1', }, { + id: 1, + color: '#ccc', children: [], grouped: { roomId: 1 }, resourceIndex: 'roomId', @@ -95,6 +243,8 @@ describe('groups utils', () => { ], groupLeafs: [ { + id: 0, + color: '#aaa', children: [], groupIndex: 0, grouped: { roomId: 0 }, @@ -102,6 +252,8 @@ describe('groups utils', () => { resourceText: 'Room 1', }, { + id: 1, + color: '#ccc', children: [], groupIndex: 1, grouped: { roomId: 1 }, @@ -113,17 +265,23 @@ describe('groups utils', () => { }); it('should group by multiple groups with correct order', () => { - expect(groupResources(resourceById, ['roomId', 'assigneeId'])).toEqual({ + expect(omitResourceData(groupResources(resourceById, ['roomId', 'assigneeId']))).toEqual({ groupTree: [ { + id: 0, + color: '#aaa', children: [ { + id: 0, + color: '#727bd2', children: [], grouped: { assigneeId: 0, roomId: 0 }, resourceIndex: 'assigneeId', resourceText: 'Samantha Bright', }, { + id: 1, + color: '#32c9ed', children: [], grouped: { assigneeId: 1, roomId: 0 }, resourceIndex: 'assigneeId', @@ -135,14 +293,20 @@ describe('groups utils', () => { resourceText: 'Room 1', }, { + id: 1, + color: '#ccc', children: [ { + id: 0, + color: '#727bd2', children: [], grouped: { assigneeId: 0, roomId: 1 }, resourceIndex: 'assigneeId', resourceText: 'Samantha Bright', }, { + id: 1, + color: '#32c9ed', children: [], grouped: { assigneeId: 1, roomId: 1 }, resourceIndex: 'assigneeId', @@ -156,6 +320,8 @@ describe('groups utils', () => { ], groupLeafs: [ { + id: 0, + color: '#727bd2', children: [], groupIndex: 0, grouped: { assigneeId: 0, roomId: 0 }, @@ -163,6 +329,8 @@ describe('groups utils', () => { resourceText: 'Samantha Bright', }, { + id: 1, + color: '#32c9ed', children: [], groupIndex: 1, grouped: { assigneeId: 1, roomId: 0 }, @@ -170,6 +338,8 @@ describe('groups utils', () => { resourceText: 'John Heart', }, { + id: 0, + color: '#727bd2', children: [], groupIndex: 2, grouped: { assigneeId: 0, roomId: 1 }, @@ -177,6 +347,8 @@ describe('groups utils', () => { resourceText: 'Samantha Bright', }, { + id: 1, + color: '#32c9ed', children: [], groupIndex: 3, grouped: { assigneeId: 1, roomId: 1 }, @@ -186,6 +358,122 @@ describe('groups utils', () => { ], }); }); + + it('should group hierarchical resource by parent-child tree', async () => { + const hierarchicalRoom = await createHierarchicalRoomResource(); + + expect(omitResourceData(groupResources({ roomId: hierarchicalRoom }, ['roomId']))).toEqual({ + groupTree: [ + { + id: 'board', + color: '#111', + resourceText: 'Board rooms', + resourceIndex: 'roomId', + grouped: { roomId: 'board' }, + children: [ + { + id: 11, + color: '#333', + resourceText: 'Room 11', + resourceIndex: 'roomId', + grouped: { roomId: 11 }, + children: [], + }, + { + id: 12, + color: '#444', + resourceText: 'Room 12', + resourceIndex: 'roomId', + grouped: { roomId: 12 }, + children: [], + }, + ], + }, + { + id: 'open', + color: '#222', + resourceText: 'Open spaces', + resourceIndex: 'roomId', + grouped: { roomId: 'open' }, + children: [ + { + id: 21, + color: '#555', + resourceText: 'Room 21', + resourceIndex: 'roomId', + grouped: { roomId: 21 }, + children: [], + }, + ], + }, + ], + groupLeafs: [ + { + id: 11, + color: '#333', + resourceText: 'Room 11', + resourceIndex: 'roomId', + grouped: { roomId: 11 }, + children: [], + groupIndex: 0, + }, + { + id: 12, + color: '#444', + resourceText: 'Room 12', + resourceIndex: 'roomId', + grouped: { roomId: 12 }, + children: [], + groupIndex: 1, + }, + { + id: 21, + color: '#555', + resourceText: 'Room 21', + resourceIndex: 'roomId', + grouped: { roomId: 21 }, + children: [], + groupIndex: 2, + }, + ], + }); + }); + + it('should use only leaf bands for hierarchical resource instead of cartesian product', async () => { + const hierarchicalRoom = await createHierarchicalRoomResource(); + + const { groupLeafs } = groupResources({ roomId: hierarchicalRoom }, ['roomId']); + + expect(groupLeafs).toHaveLength(3); + expect(groupLeafs.map((leaf) => leaf.grouped.roomId)).toEqual([11, 12, 21]); + }); + + it('should combine hierarchical and flat resources', async () => { + const hierarchicalRoom = await createHierarchicalRoomResource(); + + const { groupTree, groupLeafs } = groupResources({ + roomId: hierarchicalRoom, + assigneeId: resourceById.assigneeId, + }, ['roomId', 'assigneeId']); + + expect(groupLeafs).toHaveLength(6); + expect(groupLeafs[0].grouped).toEqual({ roomId: 11, assigneeId: 0 }); + expect(groupLeafs[1].grouped).toEqual({ roomId: 11, assigneeId: 1 }); + expect(groupTree[0].resourceText).toBe('Board rooms'); + expect(groupTree[0].children[0].children[0].resourceText).toBe('Samantha Bright'); + }); + + it('should return an empty group tree when an earlier resource in groups has an empty dataSource', async () => { + const emptyRoom = await createResourceLoader('roomId', [], 'Room'); + + expect(groupResources({ + roomId: emptyRoom, + assigneeId: resourceById.assigneeId, + }, ['roomId', 'assigneeId'])).toEqual({ + groupTree: [], + groupLeafs: [], + }); + }); }); describe('getAllGroupValues', () => { @@ -246,12 +534,12 @@ describe('groups utils', () => { it('should return resources of groupIndex', () => { expect(getResourcesByGroupIndex(groupsLeafs, resourceById, 3)).toEqual([ { - items: [{ id: 0, text: 'Samantha Bright' }], - resourceIndex: 'assigneeId', + ...resourceById.assigneeId, + items: [{ id: 0, text: 'Samantha Bright', color: '#727bd2' }], }, { - items: [{ id: 0, text: 'Room 1' }], - resourceIndex: 'roomId', + ...resourceById.roomId, + items: [{ id: 0, text: 'Room 1', color: '#aaa' }], }, ]); }); diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts index 097c7fd2cf3a..fc93d53650c2 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/group_utils.ts @@ -1,41 +1,147 @@ +import { getKeyHash } from '@js/core/utils/common'; + import type { ResourceLoader } from '../loader/resource_loader'; +import type { RawResourceData, ResourceId } from '../loader/types'; +import type { ResourceHierarchyNode } from './hierarchy_tree_utils'; import type { GroupLeaf, GroupNode } from './types'; +const isVirtualRoot = (node: GroupNode): boolean => !node.resourceIndex; + +const buildRawResourceDataById = ( + resource: ResourceLoader, +): Map, RawResourceData> => { + const rawDataById = new Map, RawResourceData>(); + + resource.data.forEach((item) => { + rawDataById.set(getKeyHash(resource.dataAccessor.get('id', item)), item); + }); + + return rawDataById; +}; + +const createFlatResourceNodes = ( + resource: ResourceLoader, +): GroupNode[] => resource.items.map((item, index) => ({ + id: item.id, + resourceText: item.text, + color: item.color, + resourceIndex: resource.resourceIndex, + grouped: { [resource.resourceIndex]: item.id }, + children: [], + resourceData: resource.data[index], +})); + +const hierarchyToGroupNodes = ( + hierarchyNodes: ResourceHierarchyNode[], + resource: ResourceLoader, + parentGrouped: Record, + rawDataById: Map, RawResourceData>, +): GroupNode[] => hierarchyNodes.map((node) => { + const grouped = { ...parentGrouped, [resource.resourceIndex]: node.data.id }; + + return { + id: node.data.id, + resourceText: node.data.text, + color: node.data.color, + resourceIndex: resource.resourceIndex, + grouped, + resourceData: rawDataById.get(getKeyHash(node.data.id)), + children: hierarchyToGroupNodes(node.children, resource, grouped, rawDataById), + }; +}); + +const collectGroupLeaves = (nodes: GroupNode[]): GroupNode[] => { + const leaves: GroupNode[] = []; + + const walk = (node: GroupNode): void => { + if (node.children.length === 0) { + leaves.push(node); + return; + } + + node.children.forEach(walk); + }; + + nodes.forEach(walk); + + return leaves; +}; + +const mergeGroupedIntoTree = ( + node: GroupNode, + parentGrouped: Record, +): GroupNode => ({ + ...node, + grouped: { ...parentGrouped, ...node.grouped }, + children: node.children.map((child) => mergeGroupedIntoTree(child, parentGrouped)), +}); + +const createResourceNodes = ( + resource: ResourceLoader, +): GroupNode[] => { + if (resource.hasHierarchy) { + return hierarchyToGroupNodes( + resource.hierarchyTree, + resource, + {}, + buildRawResourceDataById(resource), + ); + } + + return createFlatResourceNodes(resource); +}; + +const attachResourceNodes = ( + leafs: GroupNode[], + nodes: GroupNode[], +): GroupNode[] => { + const nextLeafs: GroupNode[] = []; + + leafs.forEach((leaf) => { + leaf.children = nodes.map((node) => mergeGroupedIntoTree(node, leaf.grouped)); + + leaf.children.forEach((child) => { + nextLeafs.push(...collectGroupLeaves([child])); + }); + }); + + return nextLeafs; +}; + export const groupResources = (resourceById: Record, groups: string[]): { groupTree: GroupNode[]; groupLeafs: GroupLeaf[]; } => { - if (!groups.length || Object.keys(resourceById).length === 0) { + const validGroups = groups.filter((group) => resourceById[group]); + + if (!validGroups.length) { return { groupTree: [], groupLeafs: [], }; } - const head: GroupNode[] = [{} as GroupNode]; + const head: GroupNode[] = [{ + id: '', + resourceText: '', + resourceIndex: '', + grouped: {}, + children: [], + }]; let leafs: GroupNode[] = head; - groups - .filter((group) => resourceById[group]) - .forEach((group) => { - const resource = resourceById[group]; - const nodes = resource.items.map((item) => ({ - resourceText: item.text, - resourceIndex: resource.resourceIndex, - grouped: { [resource.resourceIndex]: item.id }, - children: [], - })); - const nextLeafs: GroupNode[] = []; - - leafs.forEach((leaf) => { - leaf.children = nodes.map((node) => ({ - ...node, - grouped: { ...node.grouped, ...leaf.grouped }, - })); - nextLeafs.push(...leaf.children); - }); - leafs = nextLeafs; - }); + validGroups.forEach((group) => { + const resource = resourceById[group]; + const nodes = createResourceNodes(resource); + + if (leafs.length > 0 && isVirtualRoot(leafs[0])) { + head[0].children = nodes; + leafs = collectGroupLeaves(nodes); + return; + } + + leafs = attachResourceNodes(leafs, nodes); + }); const groupLeafs = leafs.map((leaf, index) => ({ ...leaf, diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/hierarchy_tree_utils.test.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/hierarchy_tree_utils.test.ts new file mode 100644 index 000000000000..6f3c2b597059 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/hierarchy_tree_utils.test.ts @@ -0,0 +1,151 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import type { ResourceData } from '../loader/types'; +import { + buildHierarchyTree, + collectHierarchyLeaves, +} from './hierarchy_tree_utils'; + +const item = ( + id: ResourceData['id'], + text: string, + parentId: ResourceData['parentId'] = null, +): ResourceData => ({ + id, + text, + color: '#000', + parentId, +}); + +describe('hierarchy_tree_utils', () => { + describe('buildHierarchyTree', () => { + it('should build parent-child tree from flat items', () => { + const items = [ + item('board', 'Board rooms'), + item('open', 'Open spaces'), + item(11, 'Room 11', 'board'), + item(12, 'Room 12', 'board'), + item(21, 'Room 21', 'open'), + ]; + + const tree = buildHierarchyTree(items); + + expect(tree).toHaveLength(2); + expect(tree[0].data.id).toBe('board'); + expect(tree[0].children.map((node) => node.data.id)).toEqual([11, 12]); + expect(tree[1].data.id).toBe('open'); + expect(tree[1].children.map((node) => node.data.id)).toEqual([21]); + }); + + it('should treat items with missing parent as roots', () => { + const items = [ + item(1, 'Root'), + item(2, 'Orphan', 'missing'), + ]; + + const tree = buildHierarchyTree(items); + + expect(tree).toHaveLength(2); + expect(tree.map((node) => node.data.id)).toEqual([1, 2]); + }); + + it('should support non-uniform depth', () => { + const items = [ + item('a', 'A'), + item('b', 'B', 'a'), + item('c', 'C', 'b'), + item('d', 'D'), + ]; + + const tree = buildHierarchyTree(items); + + expect(tree).toHaveLength(2); + expect(tree[0].children[0].children[0].data.id).toBe('c'); + expect(tree[1].data.id).toBe('d'); + }); + + it('should support depth greater than 3', () => { + const items = [ + item(1, 'L1'), + item(2, 'L2', 1), + item(3, 'L3', 2), + item(4, 'L4', 3), + item(5, 'L5', 4), + ]; + + const tree = buildHierarchyTree(items); + + expect(tree).toHaveLength(1); + expect( + tree[0].children[0].children[0].children[0].children[0].data.id, + ).toBe(5); + }); + + it('should break a two-node parentId cycle instead of looping forever', () => { + const items = [ + item('a', 'A', 'b'), + item('b', 'B', 'a'), + ]; + + const tree = buildHierarchyTree(items); + + expect(tree.map((node) => node.data.id)).toEqual(['a', 'b']); + expect(tree.every((node) => node.children.length === 0)).toBe(true); + }); + + it('should link a child to its parent when the id is an object compared by value', () => { + const items = [ + item({ room: 1 }, 'Board', null), + item({ room: 2 }, 'Room 11', { room: 1 }), + ]; + + const tree = buildHierarchyTree(items); + + expect(tree).toHaveLength(1); + expect(tree[0].children.map((node) => node.data.id)).toEqual([{ room: 2 }]); + }); + }); + + describe('collectHierarchyLeaves', () => { + it('should return only nodes without children in DFS order', () => { + const items = [ + item('board', 'Board rooms'), + item('open', 'Open spaces'), + item(11, 'Room 11', 'board'), + item(12, 'Room 12', 'board'), + item(21, 'Room 21', 'open'), + ]; + + const tree = buildHierarchyTree(items); + const leaves = collectHierarchyLeaves(tree); + + expect(leaves.map((leaf) => leaf.id)).toEqual([11, 12, 21]); + }); + + it('should return all items when every node is a leaf', () => { + const items = [ + item(1, 'One'), + item(2, 'Two'), + ]; + + const tree = buildHierarchyTree(items); + const leaves = collectHierarchyLeaves(tree); + + expect(leaves.map((leaf) => leaf.id)).toEqual([1, 2]); + }); + + it('should not recurse infinitely when the tree came from a parentId cycle', () => { + const items = [ + item('a', 'A', 'b'), + item('b', 'B', 'a'), + ]; + + const tree = buildHierarchyTree(items); + const leaves = collectHierarchyLeaves(tree); + + expect(leaves.map((leaf) => leaf.id)).toEqual(['a', 'b']); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/hierarchy_tree_utils.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/hierarchy_tree_utils.ts new file mode 100644 index 000000000000..29f7e295362c --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/hierarchy_tree_utils.ts @@ -0,0 +1,101 @@ +import { getKeyHash } from '@ts/core/utils/m_common'; + +import type { ResourceData } from '../loader/types'; + +export interface ResourceHierarchyNode { + data: ResourceData; + children: ResourceHierarchyNode[]; +} + +type Hash = string | number | object; +const hashOf = (id: ResourceData['id']): Hash => getKeyHash(id) as Hash; + +const isRootItem = ( + item: ResourceData, + nodeByHash: Map, +): boolean => { + const { parentId, id } = item; + + return parentId == null || !nodeByHash.has(hashOf(parentId)) || hashOf(parentId) === hashOf(id); +}; + +// Without this check, a parentId loop (A's parent is B, B's parent is A) causes a stack overflow +const isAncestorCycle = ( + id: ResourceData['id'], + parentId: ResourceData['id'], + nodeByHash: Map, +): boolean => { + const targetHash = hashOf(id); + const visited = new Set(); + let currentId: ResourceData['id'] | null | undefined = parentId; + + while (currentId != null && !visited.has(hashOf(currentId))) { + if (hashOf(currentId) === targetHash) { + return true; + } + + visited.add(hashOf(currentId)); + currentId = nodeByHash.get(hashOf(currentId))?.data.parentId; + } + + return false; +}; + +export const buildHierarchyTree = (items: ResourceData[]): ResourceHierarchyNode[] => { + const nodeByHash = new Map(); + const attachedHashes = new Set(); + + items.forEach((data) => { + nodeByHash.set(hashOf(data.id), { data, children: [] }); + }); + + items.forEach((data) => { + if (isRootItem(data, nodeByHash)) { + return; + } + + const node = nodeByHash.get(hashOf(data.id)); + const { parentId } = data; + + if (node === undefined || parentId == null) { + return; + } + + if (isAncestorCycle(data.id, parentId, nodeByHash)) { + return; + } + + const parent = nodeByHash.get(hashOf(parentId)); + + if (parent === undefined) { + return; + } + + parent.children.push(node); + attachedHashes.add(hashOf(data.id)); + }); + + return items + .filter((data) => !attachedHashes.has(hashOf(data.id))) + .map((data) => nodeByHash.get(hashOf(data.id))) + .filter((node): node is ResourceHierarchyNode => node !== undefined); +}; + +export const collectHierarchyLeaves = ( + tree: ResourceHierarchyNode[], +): ResourceData[] => { + const leaves: ResourceData[] = []; + + const walk = (node: ResourceHierarchyNode): void => { + if (node.children.length === 0) { + leaves.push(node.data); + return; + } + + node.children.forEach(walk); + }; + + tree.forEach(walk); + + return leaves; +}; diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.test.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.test.ts index 69bcb3676602..bd94a3025116 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.test.ts @@ -90,4 +90,39 @@ describe('ResourceManager', () => { expect(resources[1].isLoaded()).toBe(true); }); }); + + describe('hierarchical resources', () => { + const roomData = [ + { + id: 'board', text: 'Board rooms', color: '#111', parentId: null, + }, + { + id: 'open', text: 'Open spaces', color: '#222', parentId: null, + }, + { + id: 11, text: 'Room 11', color: '#333', parentId: 'board', + }, + { + id: 12, text: 'Room 12', color: '#444', parentId: 'board', + }, + { + id: 21, text: 'Room 21', color: '#555', parentId: 'open', + }, + ]; + + it('should expose hierarchy tree and leaf items for hierarchical resource', async () => { + const manager = new ResourceManager([{ + fieldExpr: 'roomId', + parentIdExpr: 'parentId', + dataSource: roomData, + label: 'Room', + }]); + + await manager.loadGroupResources(['roomId']); + + expect(manager.isHierarchicalResource('roomId')).toBe(true); + expect(manager.getResourceLeafItems('roomId').map((item) => item.id)).toEqual([11, 12, 21]); + expect(manager.getResourceHierarchyTree('roomId').map((node) => node.data.id)).toEqual(['board', 'open']); + }); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.ts index 98a66f5c01f7..3bd578772216 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.ts @@ -4,6 +4,7 @@ import { getResourceIndex } from '../data_accessor/appointment_resource_data_acc import { ResourceLoader } from '../loader/resource_loader'; import type { ResourceConfig, + ResourceData, } from '../loader/types'; import { getAppointmentColor } from './appointment_color_utils'; import type { AppointmentResource } from './appointment_groups_utils'; @@ -12,6 +13,7 @@ import { getAppointmentResources, } from './appointment_groups_utils'; import { groupResources } from './group_utils'; +import type { ResourceHierarchyNode } from './hierarchy_tree_utils'; import type { GroupLeaf, GroupNode } from './types'; export class ResourceManager { @@ -60,6 +62,20 @@ export class ResourceManager { .filter(Boolean); } + public isHierarchicalResource(resourceIndex: string): boolean { + return Boolean(this.resourceById[resourceIndex]?.hasHierarchy); + } + + public getResourceHierarchyTree(resourceIndex: string): ResourceHierarchyNode[] { + return this.resourceById[resourceIndex]?.hierarchyTree ?? []; + } + + public getResourceLeafItems(resourceIndex: string): ResourceData[] { + const resource = this.resourceById[resourceIndex]; + + return resource?.leafItems ?? []; + } + public async loadAppointmentsResources( items: SafeAppointment[], forceReload = false, diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/types.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/types.ts index 3d95dec0e4b5..40f93d425376 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/types.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/types.ts @@ -1,13 +1,16 @@ -import type { ResourceId } from '../loader/types'; +import type { RawResourceData, ResourceId } from '../loader/types'; export type GroupValues = Record; export type RawGroupValues = Record; export interface GroupNode { + id: ResourceId; resourceText: string; + color?: string; resourceIndex: string; grouped: Record; children: GroupNode[]; + resourceData?: RawResourceData; } export interface GroupLeaf extends GroupNode { diff --git a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/filter_by_attributes/is_appointment_matched_resources.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/filter_by_attributes/is_appointment_matched_resources.test.ts index 8dd101a8d874..b383dcbffda2 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/filter_by_attributes/is_appointment_matched_resources.test.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/filter_by_attributes/is_appointment_matched_resources.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, } from '@jest/globals'; +import { hierarchicalRoomsConfigMock } from '@ts/scheduler/__mock__/resource_manager.mock'; import { ResourceLoader } from '../../../../utils/loader/resource_loader'; import { @@ -61,4 +62,41 @@ describe('isAppointmentMatchedResources', () => { [assignee], )).toBe(false); }); + + describe('hierarchical resource', () => { + const loadRoom = async (allowMultiple = false): Promise => { + const room = new ResourceLoader({ ...hierarchicalRoomsConfigMock, allowMultiple }); + await room.load(); + + return room; + }; + + it('should match a leaf id', async () => { + expect(isAppointmentMatchedResources( + { roomId: 21 } as any, + [await loadRoom()], + )).toBe(true); + }); + + it('should not match a parent id', async () => { + expect(isAppointmentMatchedResources( + { roomId: 'board' } as any, + [await loadRoom()], + )).toBe(false); + }); + + it('should match an allowMultiple appointment by any of its leaf ids', async () => { + expect(isAppointmentMatchedResources( + { roomId: ['board', 21] } as any, + [await loadRoom(true)], + )).toBe(true); + }); + + it('should not match an allowMultiple appointment bound to parent ids only', async () => { + expect(isAppointmentMatchedResources( + { roomId: ['board', 'open'] } as any, + [await loadRoom(true)], + )).toBe(false); + }); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/filter_by_attributes/is_appointment_matched_resources.ts b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/filter_by_attributes/is_appointment_matched_resources.ts index f7768a79502b..378a261611be 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/filter_by_attributes/is_appointment_matched_resources.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/filter_by_attributes/is_appointment_matched_resources.ts @@ -16,9 +16,10 @@ export const isAppointmentMatchedResources = ( return groupsResources.every((resource) => { const value = appointmentGroupValues[resource.resourceIndex]; + const validItems = resource.hasHierarchy ? resource.leafItems : resource.items; return value?.some( - (id) => resource.items.some( + (id) => validItems.some( (item) => equalByValue(id, item.id), ), ); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_group_index.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_group_index.test.ts index 00335fb8e37f..9f8850928c10 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_group_index.test.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_group_index.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from '@jest/globals'; +import { hierarchicalRoomsConfigMock } from '@ts/scheduler/__mock__/resource_manager.mock'; import type { ResourceConfig } from '../../../utils/loader/types'; import { ResourceManager } from '../../../utils/resource_manager/resource_manager'; @@ -67,4 +68,37 @@ describe('splitByGroupIndex', () => { { ...items[2], groupIndex: 5 }, ]); }); + + describe('hierarchical resource', () => { + // Leaf group order: 11 → 0, 12 → 1, 21 → 2, solo → 3 + const asItem = (roomId: unknown): MinimalAppointmentEntity => ({ + itemData: { roomId } as unknown as MinimalAppointmentEntity['itemData'], + } as MinimalAppointmentEntity); + + it('should set groupIndex of the appointment leaf', async () => { + const options = await getFilterOptions([{ ...hierarchicalRoomsConfigMock }]); + const item = asItem(21); + + expect(splitByGroupIndex([item], options)).toEqual([{ ...item, groupIndex: 2 }]); + }); + + it('should drop an appointment that matches no leaf', async () => { + const options = await getFilterOptions([{ ...hierarchicalRoomsConfigMock }]); + + expect(splitByGroupIndex([asItem('board')], options)).toEqual([]); + }); + + it('should split an allowMultiple appointment across its leaf bands', async () => { + const options = await getFilterOptions([{ + ...hierarchicalRoomsConfigMock, + allowMultiple: true, + }]); + const item = asItem(['board', 12, 'solo']); + + expect(splitByGroupIndex([item], options)).toEqual([ + { ...item, groupIndex: 1 }, + { ...item, groupIndex: 3 }, + ]); + }); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/agenda.ts b/packages/devextreme/js/__internal/scheduler/workspaces/agenda.ts index b007f58caa95..c7eac2219570 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/agenda.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/agenda.ts @@ -21,7 +21,7 @@ import { GROUP_ROW_CLASS, TIME_PANEL_CLASS, } from '../classes'; -import { agendaUtils, formatWeekday, getVerticalGroupCountClass } from '../r1/utils/index'; +import { agendaUtils, formatWeekday } from '../r1/utils/index'; import tableCreatorModule, { type GroupRows } from '../table_creator'; import type { ResourceId } from '../utils/loader/types'; import { VIEWS } from '../utils/options/constants_view'; @@ -102,7 +102,6 @@ class SchedulerAgenda extends WorkSpace { if (this.$groupTable) { this.$groupTable.remove(); this.$groupTable = null; - this.detachGroupCountClass(); } } else if (!this.$groupTable) { this.initGroupTable(); @@ -230,13 +229,6 @@ class SchedulerAgenda extends WorkSpace { return rows.every((groupRow) => groupRow.every((cell) => !cell)); } - protected override attachGroupCountClass(): void { - const className = getVerticalGroupCountClass(this.option().groups); - if (className) { - this.$element().addClass(className); - } - } - private removeEmptyRows(rows: number[][]): number[][] { const isEmpty = (data: number[]): boolean => !data.some((value) => value > 0); return rows.filter((row) => row.length && !isEmpty(row)); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/view_data_provider.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/view_data_provider.ts index bdf42413edca..da580482fa07 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/view_data_provider.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/view_data_provider.ts @@ -196,15 +196,21 @@ export default class ViewDataProvider { getGroupPanelData(options: ViewDataProviderOptions): GroupPanelData | undefined { const renderOptions = this.transformRenderOptions(options); - const groupResources = renderOptions.getResourceManager().groupResources(); + const resourceManager = renderOptions.getResourceManager(); + const { groupsTree, groups } = resourceManager; - if (groupResources.length > 0) { + if (groupsTree.length > 0) { const cellCount = this.getCellCount(renderOptions); + const hasHierarchy = groups.some( + (group) => resourceManager.isHierarchicalResource(group), + ); + return getGroupPanelData( - groupResources, + groupsTree, cellCount, renderOptions.isGroupedByDate, renderOptions.isGroupedByDate ? 1 : cellCount, + hasHierarchy, ); } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts index bd0149d8a5e3..ebc8991954fa 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts @@ -78,7 +78,6 @@ import { GROUP_HEADER_CONTENT_CLASS, GROUP_ROW_CLASS, TIME_PANEL_CLASS, - VERTICAL_GROUP_COUNT_CLASSES, VIRTUAL_CELL_CLASS, } from '../classes'; import { APPOINTMENT_SETTINGS_KEY } from '../constants'; @@ -2389,7 +2388,6 @@ class SchedulerWorkSpace extends Widget { }; if (this.option().groups?.length) { - this.attachGroupCountClass(); const $groupHeaderContainer = this.getGroupHeaderContainer(); if ($groupHeaderContainer) { this.renderRenovatedComponent( @@ -2399,8 +2397,6 @@ class SchedulerWorkSpace extends Widget { options, ); } - } else { - this.detachGroupCountClass(); } } @@ -2473,12 +2469,6 @@ class SchedulerWorkSpace extends Widget { } renderRHeaderPanel(isRenderDateHeader = true): void { - if (this.option().groups?.length) { - this.attachGroupCountClass(); - } else { - this.detachGroupCountClass(); - } - this.renderRenovatedComponent( this.$thead, this.renovatedHeaderPanelComponent, @@ -3219,20 +3209,6 @@ class SchedulerWorkSpace extends Widget { protected setIndicationUpdateInterval(): void { return noop(); } - protected detachGroupCountClass(): void { - VERTICAL_GROUP_COUNT_CLASSES.forEach((className) => { - this.$element().removeClass(className); - }); - } - - protected attachGroupCountClass(): void { - const className = this.groupedStrategy.getGroupCountClass(this.option().groups); - - if (className) { - this.$element().addClass(className); - } - } - protected getDateHeaderTemplate(): TemplateBase | null | undefined { return this.option().dateCellTemplate; } @@ -3351,12 +3327,9 @@ class SchedulerWorkSpace extends Widget { let cellTemplates: (() => dxElementWrapper)[] = []; if (groupCount && $container) { const groupRows = this.makeGroupRows(this.option().groups, this.option().groupByDate); - this.attachGroupCountClass(); const { elements } = groupRows; $container.append(Array.isArray(elements) ? elements : elements.toArray()); cellTemplates = groupRows.cellTemplates; - } else { - this.detachGroupCountClass(); } return cellTemplates; diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_horizontal.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_horizontal.ts index 6c7c18e41408..5e852d38e91a 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_horizontal.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_horizontal.ts @@ -8,7 +8,6 @@ import type { import { WORK_SPACE_BORDER_PX } from '@ts/scheduler/workspaces/const'; import { FIRST_GROUP_CELL_CLASS, LAST_GROUP_CELL_CLASS } from '../classes'; -import type { ResourceLoader } from '../utils/loader/resource_loader'; import type { GroupedStrategyConfig } from './work_space_grouped_strategy_config'; class HorizontalGroupedStrategy { @@ -77,11 +76,6 @@ class HorizontalGroupedStrategy { return this.config.getAllDayHeight(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getGroupCountClass(groups: ResourceLoader[]): string | undefined { - return undefined; - } - getLeftOffset(): number { return this.config.getTimePanelWidth(); } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.test.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.test.ts index 2913d192235d..73eb05320668 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.test.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.test.ts @@ -8,12 +8,10 @@ import VerticalGroupedStrategy from './work_space_grouped_strategy_vertical'; jest.mock('@ts/scheduler/r1/utils/index', (): { calculateDayDuration: (startDayHour: number, endDayHour: number) => number; - getVerticalGroupCountClass: () => undefined; } => ({ calculateDayDuration: (startDayHour: number, endDayHour: number): number => ( endDayHour - startDayHour ), - getVerticalGroupCountClass: (): undefined => undefined, })); const createElement = ({ diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.ts index 8018efecb51c..bf3146d9269d 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space_grouped_strategy_vertical.ts @@ -1,12 +1,11 @@ import type { dxElementWrapper } from '@js/core/renderer'; import { getBoundingRect } from '@js/core/utils/position'; -import { calculateDayDuration, getVerticalGroupCountClass } from '@ts/scheduler/r1/utils/index'; +import { calculateDayDuration } from '@ts/scheduler/r1/utils/index'; import type { CellPositionData, GroupBoundsOffset } from '@ts/scheduler/types'; import { WORK_SPACE_BORDER_PX } from '@ts/scheduler/workspaces/const'; import { FIRST_GROUP_CELL_CLASS, LAST_GROUP_CELL_CLASS } from '../classes'; import { Cache } from '../global_cache'; -import type { ResourceLoader } from '../utils/loader/resource_loader'; import type { GroupedStrategyConfig } from './work_space_grouped_strategy_config'; class VerticalGroupedStrategy { @@ -77,10 +76,6 @@ class VerticalGroupedStrategy { return 0; } - getGroupCountClass(groups: ResourceLoader[]): string | undefined { - return getVerticalGroupCountClass(groups); - } - getLeftOffset(): number { return this.config.getTimePanelWidth() + this.config.getGroupTableWidth(); } diff --git a/packages/devextreme/js/ui/scheduler.d.ts b/packages/devextreme/js/ui/scheduler.d.ts index fc511bc8af95..5cf074cdba1b 100644 --- a/packages/devextreme/js/ui/scheduler.d.ts +++ b/packages/devextreme/js/ui/scheduler.d.ts @@ -940,6 +940,11 @@ export interface dxSchedulerOptions extends WidgetOptions { * @default "" */ label?: string; + /** + * @docid + * @default undefined + */ + parentIdExpr?: string; /** * @docid * @default false diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.resources.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.resources.tests.js index e9510824e838..28b70a3d09cd 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.resources.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.resources.tests.js @@ -39,7 +39,7 @@ QUnit.module('Integration: Resources', moduleConfig, () => { }, 'appointment2': { top: 202, - left: 430 + left: 411 } }, { 'appointment1': { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/timeline.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/timeline.markup.tests.js index 0f7affa43e3e..a010732318f0 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/timeline.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/timeline.markup.tests.js @@ -138,26 +138,6 @@ QUnit.module('Timeline markup', moduleConfig, () => { assert.equal($firstColumnCells.length, 2, 'Cell count is OK'); assert.equal($secondColumnCells.length, 4, 'Cell count is OK'); }); - - QUnit.test('Timeline should have correct group-count class depending on group count', async function(assert) { - const $element = this.instance.$element(); - - await applyWorkspaceGroups(this.instance, [{ - label: 'one', - fieldExpr: 'one', - dataSource: [{ id: 1, text: 'a' }, { id: 2, text: 'b' }] - }, { - label: 'two', - fieldExpr: 'two', - dataSource: [{ id: 1, text: '1' }, { id: 2, text: '2' }] - }]); - - assert.ok($element.hasClass('dx-scheduler-group-column-count-two'), 'Correct class'); - - await applyWorkspaceGroups(this.instance, []); - - assert.notOk($element.hasClass('dx-scheduler-group-column-count-two'), 'group-count class was not applied'); - }); }); let timelineDayModuleConfig = { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/view_data_provider.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/view_data_provider.tests.js index ab17d137d9a1..f9c7962bff81 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/view_data_provider.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/view_data_provider.tests.js @@ -1950,30 +1950,61 @@ module('View Data Provider', { renderOptions: await getRenderOptions(), }); - const expectedGroupPanelData = { - baseColSpan: 2, - groupPanelItems: [[{ - color: 'red', - data: { - id: 1, - color: 'red', - text: 'First group' - }, + const firstGroupCell = { + id: 1, + text: 'First group', + color: 'red', + resourceIndex: 'groupId', + data: { id: 1, - key: '0_groupId_1', - resourceName: 'groupId', + color: 'red', text: 'First group' - }, { - color: 'green', - data: { - id: 2, - color: 'green', - text: 'Second group' - }, + } + }; + const secondGroupCell = { + id: 2, + text: 'Second group', + color: 'green', + resourceIndex: 'groupId', + data: { id: 2, - key: '0_groupId_2', - resourceName: 'groupId', + color: 'green', text: 'Second group' + } + }; + + const expectedGroupPanelData = { + baseColSpan: 2, + maxDepth: 1, + columnCountPerGroup: 2, + hasHierarchy: false, + groupTree: [{ + ...firstGroupCell, + key: 'groupId_1', + path: [firstGroupCell], + leafCount: 1, + children: [], + }, { + ...secondGroupCell, + key: 'groupId_2', + path: [secondGroupCell], + leafCount: 1, + children: [], + }], + groupPanelItems: [[{ + ...firstGroupCell, + key: 'groupId_1', + isLeaf: true, + isLastColumn: false, + path: [firstGroupCell], + colSpan: 2 + }, { + ...secondGroupCell, + key: 'groupId_2', + isLeaf: true, + isLastColumn: true, + path: [secondGroupCell], + colSpan: 2 }]] }; diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 8b1e40dc981f..6af5e097c9e9 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -27319,6 +27319,10 @@ declare module DevExpress.ui { * [descr:dxSchedulerOptions.resources.label] */ label?: string; + /** + * [descr:dxSchedulerOptions.resources.parentIdExpr] + */ + parentIdExpr?: string; /** * [descr:dxSchedulerOptions.resources.useColorAsDefault] */