From de2afa82d742103fac825df3b67c203dd8813be9 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Mon, 20 Oct 2025 03:56:55 -0300 Subject: [PATCH 1/2] Appointment form customization with dynamic item handling --- .../appointment_popup.test.ts | 336 ++++++++++++++++++ .../customize_form_items.test.ts | 262 ++++++++++++++ .../m_customize_form_items.ts | 103 ++++++ .../scheduler/appointment_popup/m_form.ts | 85 +++-- 4 files changed, 759 insertions(+), 27 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/appointment_popup/customize_form_items.test.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointment_popup/m_customize_form_items.ts 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 edabf6b9cbc4..b9ff1e311917 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 @@ -3,6 +3,7 @@ import { } from '@jest/globals'; import $ from '@js/core/renderer'; import type dxDateBox from '@js/ui/date_box'; +import type { GroupItem, Item as FormItem } from '@js/ui/form'; import { toMilliseconds } from '@ts/utils/toMilliseconds'; import fx from '../../../common/core/animation/fx'; @@ -1445,3 +1446,338 @@ describe('Timezone Editors', () => { it.todo('timeZone editor should have correct display value for timezones with different offsets'); it.todo('dataSource of timezoneEditor should be filtered'); }); + +describe('Customize form items', () => { + beforeEach(() => { + fx.off = true; + setupSchedulerTestEnvironment(); + }); + + afterEach(() => { + fx.off = false; + document.body.innerHTML = ''; + jest.useRealTimers(); + }); + + describe('Basic form customization', () => { + it('should use default form when editing.items is not set', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + + expect(formItems).toBeDefined(); + expect(formItems?.length).toBeGreaterThan(0); + }); + + it('should show empty form when editing.items is empty array', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: [], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + + expect(formItems?.length ?? 0).toBe(0); + }); + + it('should show mainGroup when specified in string array', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: ['mainGroup'], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + + expect(formItems?.length).toBe(1); + expect(formItems?.[0]?.name).toBe('mainGroup'); + }); + + it('should hide group when visible is false', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: [{ name: 'mainGroup', visible: false }], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + + expect(formItems?.length).toBe(1); + expect(formItems?.[0]?.visible).toBe(false); + }); + + it('should show group when visible is true', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: [{ name: 'mainGroup', visible: true }], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + + expect(formItems?.length).toBe(1); + expect(formItems?.[0]?.visible).toBe(true); + }); + + it('should filter children when items array is specified', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: [{ + name: 'mainGroup', + visible: true, + items: ['subjectGroup'], + }], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + const mainGroup = formItems?.[0] as GroupItem; + + expect(formItems?.length).toBe(1); + expect(mainGroup?.items?.length).toBe(1); + expect(mainGroup?.items?.[0]?.name).toBe('subjectGroup'); + }); + + it('should handle non-existent groups gracefully', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: ['nonExistentGroup'], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + + expect(formItems?.length ?? 0).toBe(1); + }); + }); + + describe('Form customization with editing.items', () => { + it('should handle empty items array', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: [], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + expect(formItems?.length).toBe(0); + }); + + it('should handle string array configuration', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: ['mainGroup'], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + expect(formItems?.length).toBe(1); + expect((formItems?.[0] as GroupItem)?.name).toBe('mainGroup'); + }); + + it('should handle object configuration with visible false', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: [{ name: 'mainGroup', visible: false }], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + expect(formItems?.length).toBe(1); + expect(formItems?.[0]?.visible).toBe(false); + }); + + it('should handle object configuration with custom items', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: [{ + name: 'mainGroup', + items: ['subjectGroup', 'dateGroup'], + }], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + const mainGroup = formItems?.[0] as GroupItem; + expect(mainGroup?.items?.length).toBe(2); + expect((mainGroup?.items?.[0] as GroupItem)?.name).toBe('subjectGroup'); + expect((mainGroup?.items?.[1] as GroupItem)?.name).toBe('dateGroup'); + }); + + it('should handle non-existent group names', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: ['nonExistentGroup'], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + expect(formItems?.length).toBe(1); + }); + + it('should handle undefined items', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: undefined, + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + expect(formItems?.length).toBeGreaterThan(0); + }); + + it('should handle mixed configurations', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: [ + 'mainGroup', + { name: 'mainGroup', visible: false }, + ], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + expect(formItems?.length).toBe(2); + expect((formItems?.[0] as any)?.name).toBe('mainGroup'); + expect((formItems?.[1] as any)?.name).toBe('mainGroup'); + expect(formItems?.[1]?.visible).toBe(false); + }); + + it('should handle empty items array in object config', async () => { + const { scheduler, POM } = await createScheduler({ + ...getDefaultConfig(), + editing: { + allowAdding: true, + allowUpdating: true, + form: { + items: [{ + name: 'mainGroup', + items: [], + }], + }, + }, + }); + + scheduler.showAppointmentPopup(commonAppointment); + + const { form } = POM.popup; + const formItems = form.option('items') as FormItem[]; + const mainGroup = formItems?.[0] as any; + expect(mainGroup?.items?.length).toBe(0); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointment_popup/customize_form_items.test.ts b/packages/devextreme/js/__internal/scheduler/appointment_popup/customize_form_items.test.ts new file mode 100644 index 000000000000..f5f0e065f352 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointment_popup/customize_form_items.test.ts @@ -0,0 +1,262 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import { type ConfigItem, customizeFormItems, type FormItem } from './m_customize_form_items'; + +const subjectGroup: FormItem = { + name: 'subjectGroup', + itemType: 'group', + cssClass: 'dx-scheduler-form-subject-group dx-scheduler-form-group-with-icon', +}; + +const dateGroup: FormItem = { + name: 'dateGroup', + itemType: 'group', + cssClass: 'dx-scheduler-form-date-range-group dx-scheduler-form-group-with-icon', +}; + +const repeatGroup: FormItem = { + name: 'repeatGroup', + itemType: 'group', + cssClass: 'dx-scheduler-form-repeat-group dx-scheduler-form-group-with-icon', +}; + +const resourcesGroup: FormItem = { + name: 'resourcesGroup', + itemType: 'group', + cssClass: 'dx-scheduler-form-resources-group dx-scheduler-form-group-with-icon', +}; + +const descriptionGroup: FormItem = { + name: 'descriptionGroup', + itemType: 'group', + cssClass: 'dx-scheduler-form-description-group dx-scheduler-form-group-with-icon', +}; + +const mainGroup: FormItem = { + items: [subjectGroup, dateGroup, repeatGroup, resourcesGroup, descriptionGroup], + name: 'mainGroup', + itemType: 'group', + cssClass: 'dx-scheduler-form-main-group', + colSpan: 1, +}; + +const recurrenceGroup: FormItem = { + name: 'recurrenceGroup', + itemType: 'group', + cssClass: 'dx-scheduler-form-recurrence-group dx-scheduler-form-recurrence-hidden', + colSpan: 1, +}; + +const mainTestCase: FormItem[] = [mainGroup, recurrenceGroup]; + +describe('customizeFormItems', () => { + it('should return original items when no customization provided', () => { + const result = customizeFormItems(mainTestCase); + + expect(result).toEqual(mainTestCase); + }); + + it('should not mutate original data', () => { + const originalMainTestCase = JSON.parse(JSON.stringify(mainTestCase)); + const originalMainGroup = JSON.parse(JSON.stringify(mainGroup)); + const originalSubjectGroup = JSON.parse(JSON.stringify(subjectGroup)); + + expect(mainGroup.items).toBe(mainGroup.items); + + customizeFormItems(mainTestCase, ['mainGroup']); + customizeFormItems(mainTestCase, ['subjectGroup', 'dateGroup']); + customizeFormItems(mainTestCase, [{ name: 'mainGroup', items: [] }]); + customizeFormItems(mainTestCase, [{ name: 'mainGroup', items: ['subjectGroup'] }] as ConfigItem[]); + + expect(mainTestCase).toEqual(originalMainTestCase); + expect(mainGroup).toEqual(originalMainGroup); + expect(subjectGroup).toEqual(originalSubjectGroup); + + expect(mainGroup.items).toHaveLength(5); + expect(mainGroup.items?.[0]).toBe(subjectGroup); + expect(mainGroup.items?.[1]).toBe(dateGroup); + }); + + it('should show only mainGroup when specified by object', () => { + const customizeItems = [ + { + name: 'mainGroup', + }, + ]; + const result = customizeFormItems(mainTestCase, customizeItems); + + expect(result).toEqual([mainGroup]); + }); + + it('should show only mainGroup when specified by string', () => { + const result = customizeFormItems(mainTestCase, ['mainGroup']); + + expect(result).toEqual([mainGroup]); + }); + + it('should extract specific items from parent group', () => { + const result = customizeFormItems(mainTestCase, ['subjectGroup', 'dateGroup']); + + expect(result).toEqual([subjectGroup, dateGroup]); + }); + + it('should create empty mainGroup when items array is empty', () => { + const result = customizeFormItems(mainTestCase, [{ name: 'mainGroup', items: [], visible: false }]); + + expect(result).toEqual([{ + ...mainGroup, + items: [], + visible: false, + }]); + }); + + it('should change order of top-level groups', () => { + const customizeItems = [ + 'recurrenceGroup', + 'mainGroup', + ]; + const result = customizeFormItems(mainTestCase, customizeItems); + + expect(result).toEqual([recurrenceGroup, mainGroup]); + }); + + it('should extract item from group and change order', () => { + const customizeItems = [ + 'subjectGroup', + 'recurrenceGroup', + 'mainGroup', + ]; + const result = customizeFormItems(mainTestCase, customizeItems); + const expectedMainGroup = { + ...mainGroup, + items: [dateGroup, repeatGroup, resourcesGroup, descriptionGroup], + }; + + expect(result).toEqual([subjectGroup, recurrenceGroup, expectedMainGroup]); + }); + + it('should extract multiple items from parent group', () => { + const customizeItems = [ + 'subjectGroup', + 'repeatGroup', + 'mainGroup', + ]; + const result = customizeFormItems(mainTestCase, customizeItems); + + const expectedMainGroup = { + ...mainGroup, + items: [dateGroup, resourcesGroup, descriptionGroup], + }; + + expect(result).toEqual([subjectGroup, repeatGroup, expectedMainGroup]); + }); + + it('should create custom form item when name not found', () => { + const result = customizeFormItems(mainTestCase, ['customItem']); + + expect(result).toEqual([{ + name: 'customItem', + itemType: 'simple', + dataField: 'customItem', + editorType: 'dxTextBox', + }]); + }); + + it('should create custom group with children', () => { + const customizeItems = [ + { + name: 'customGroup', + itemType: 'group' as const, + items: ['child1', 'child2'], + }, + ]; + const result = customizeFormItems(mainTestCase, customizeItems); + + expect(result).toEqual([{ + name: 'customGroup', + itemType: 'group', + items: [{ + name: 'child1', + itemType: 'simple', + dataField: 'child1', + editorType: 'dxTextBox', + }, { + name: 'child2', + itemType: 'simple', + dataField: 'child2', + editorType: 'dxTextBox', + }], + }]); + }); + + it('should create custom group with visibility control', () => { + const customizeItems = [ + { + name: 'hiddenGroup', + itemType: 'group' as const, + visible: false, + items: ['field1'], + }, + ]; + const result = customizeFormItems(mainTestCase, customizeItems); + + expect(result).toEqual([{ + name: 'hiddenGroup', + itemType: 'group', + visible: false, + items: [{ + name: 'field1', + itemType: 'simple', + dataField: 'field1', + editorType: 'dxTextBox', + }], + }]); + }); + + it('should customize mainGroup with specific items as array', () => { + const result = customizeFormItems( + mainTestCase, + [{ name: 'mainGroup', items: ['subjectGroup', 'dateGroup'] }] as ConfigItem[], + ); + + expect(result).toEqual([{ + ...mainGroup, + items: [subjectGroup, dateGroup], + }]); + }); + + it('should customize mainGroup with specific items as objects', () => { + const result = customizeFormItems(mainTestCase, [{ name: 'mainGroup', items: [{ name: 'subjectGroup' }, { name: 'dateGroup' }] }]); + + expect(result).toEqual([{ + ...mainGroup, + items: [subjectGroup, dateGroup], + }]); + }); + + it('should create nested custom groups', () => { + const result = customizeFormItems(mainTestCase, [{ + name: 'mainGroup', + items: [ + { name: 'several', itemType: 'group' as const, items: ['subjectGroup'] }, + 'dateGroup', + ], + }] as ConfigItem[]); + + expect(result).toEqual([{ + ...mainGroup, + items: [{ name: 'several', itemType: 'group', items: [subjectGroup] }, dateGroup], + }]); + }); + + it('should set item visibility property', () => { + const result = customizeFormItems(mainTestCase, [{ name: 'mainGroup', items: [{ name: 'subjectGroup', visible: false }, { name: 'dateGroup' }] }]); + + expect(result).toEqual([{ + ...mainGroup, + items: [{ ...subjectGroup, visible: false }, dateGroup], + }]); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointment_popup/m_customize_form_items.ts b/packages/devextreme/js/__internal/scheduler/appointment_popup/m_customize_form_items.ts new file mode 100644 index 000000000000..6a4818ddbb3f --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointment_popup/m_customize_form_items.ts @@ -0,0 +1,103 @@ +import { extend } from '@js/core/utils/extend'; +import type { + FormItemComponent, + GroupItem, + Item as FormItem, + TabbedItem, +} from '@js/ui/form'; + +type ConfigItem = string | FormItem; + +const isGroupItem = (item: FormItem): item is GroupItem => 'items' in item && !('tabs' in item); +const isTabbedItem = (item: FormItem): item is TabbedItem => 'tabs' in item; + +const createFormItemFromConfig = (configItem: ConfigItem): FormItem => ( + typeof configItem === 'string' + ? { + itemType: 'simple', + editorType: 'dxTextBox' as FormItemComponent, + name: configItem, + dataField: configItem, + } + : { ...configItem } +); + +const getChildren = (item: FormItem): FormItem[] => [ + ...isGroupItem(item) ? item.items ?? [] : [], + ...isTabbedItem(item) ? item.tabs?.flatMap((tab) => tab.items ?? []) ?? [] : [], +]; + +const buildFormItemsMap = ( + items: FormItem[], + map: Map = new Map(), +): Map => items.reduce( + (accumulator, item) => { + if (item.name) { + accumulator.set(item.name, { ...item }); + } + return buildFormItemsMap(getChildren(item), accumulator); + }, + map, +); + +const removeItemFromGroups = ( + itemName: string, + itemsMap: Map, +): void => { + Array.from(itemsMap.values()).forEach((group) => { + if (isGroupItem(group) && group.items) { + group.items = group.items.filter((item) => item.name !== itemName); + } + }); +}; + +const getItemName = (configure: ConfigItem): string | undefined => ( + typeof configure === 'string' ? configure : configure.name +); + +const shouldMergeWithExisting = (configure: ConfigItem): configure is FormItem => typeof configure === 'object'; + +const hasChildItems = (configure: ConfigItem): configure is GroupItem => typeof configure === 'object' +&& isGroupItem(configure) && Boolean(configure.items); + +const baseResolveItem = (map: Map) => (configure: ConfigItem): FormItem => { + const itemName = getItemName(configure); + const existingItem = itemName ? map.get(itemName) : undefined; + + if (!existingItem || !itemName) { + return createFormItemFromConfig(configure); + } + + removeItemFromGroups(itemName, map); + + return shouldMergeWithExisting(configure) + ? extend(true, {}, existingItem, configure) as FormItem + : existingItem; +}; + +const customizeFormItems = ( + items: FormItem[], + userConfig?: ConfigItem[], +): FormItem[] => { + if (!userConfig) { + return items; + } + + const map = buildFormItemsMap(items); + const resolveItem = baseResolveItem(map); + + const customize = (config: ConfigItem[]): FormItem[] => config.map((configure): FormItem => { + const formItem = resolveItem(configure); + + if (isGroupItem(formItem) && hasChildItems(configure) && configure.items) { + return { ...formItem, items: customize(configure.items) }; + } + + return formItem; + }); + + return customize(userConfig); +}; + +export { customizeFormItems }; +export type { ConfigItem, FormItem }; diff --git a/packages/devextreme/js/__internal/scheduler/appointment_popup/m_form.ts b/packages/devextreme/js/__internal/scheduler/appointment_popup/m_form.ts index 7ee4c483148a..e305459ba379 100644 --- a/packages/devextreme/js/__internal/scheduler/appointment_popup/m_form.ts +++ b/packages/devextreme/js/__internal/scheduler/appointment_popup/m_form.ts @@ -23,6 +23,7 @@ import type { ResourceLoader } from '../utils/loader/resource_loader'; 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'; +import { customizeFormItems } from './m_customize_form_items'; import { RecurrenceForm } from './m_recurrence_form'; import { createFormIconTemplate, getStartDateCommonConfig, RecurrenceRule } from './utils'; @@ -64,14 +65,6 @@ const CLASSES = { recurrenceHidden: 'dx-scheduler-form-recurrence-hidden', }; -const EDITOR_NAMES = { - startDate: 'startDateEditor', - startTime: 'startTimeEditor', - endDate: 'endDateEditor', - endTime: 'endTimeEditor', - repeat: 'repeatEditor', -}; - const repeatSelectBoxItems = [ { recurrence: 'dxScheduler-recurrenceNever', @@ -108,6 +101,16 @@ const createTimeZoneDataSource = (): DataSource => new DataSource({ }); const MAIN_GROUP_NAME = 'mainGroup'; +const DATE_GROUP_NAME = 'dateGroup'; +const START_DATE_GROUP_NAME = 'startDateGroup'; +const END_DATE_GROUP_NAME = 'endDateGroup'; +const RESOURCES_GROUP_NAME = 'resourcesGroup'; + +const START_DATE_EDITOR_NAME = 'startDate'; +const START_TIME_EDITOR_NAME = 'startTime'; +const END_DATE_EDITOR_NAME = 'endDate'; +const END_TIME_EDITOR_NAME = 'endTime'; +const REPEAT_EDITOR_NAME = 'repeat'; export class AppointmentForm { private readonly scheduler: any; @@ -192,7 +195,10 @@ export class AppointmentForm { this.setStylingModeToEditors(mainGroup, showMainGroupIcons); this.setStylingModeToEditors(recurrenceGroup, showRecurrenceGroupIcons); - this.createForm(items); + const editingConfig = this.scheduler.getEditingConfig(); + const customizedItems = customizeFormItems(items, editingConfig?.form?.items); + + this.createForm(customizedItems); } private getIconsShowMode(): 'main' | 'recurrence' | 'both' | 'none' { @@ -280,6 +286,7 @@ export class AppointmentForm { const { textExpr } = this.scheduler.getDataAccessors().expr; return { + name: 'subjectGroup', itemType: 'group', cssClass: `${CLASSES.subjectGroup} ${CLASSES.groupWithIcon}`, colCount: 2, @@ -288,11 +295,13 @@ export class AppointmentForm { }, items: [ { + name: 'subjectIcon', colSpan: 1, cssClass: CLASSES.formIcon, template: createFormIconTemplate('isnotblank'), }, { + name: 'subject', colSpan: 1, itemType: 'simple', cssClass: CLASSES.textEditor, @@ -308,6 +317,7 @@ export class AppointmentForm { private createDateRangeGroup(): GroupItem { return { + name: DATE_GROUP_NAME, itemType: 'group', cssClass: `${CLASSES.dateRangeGroup} ${CLASSES.groupWithIcon}`, colCount: 2, @@ -316,6 +326,7 @@ export class AppointmentForm { }, items: [ { + name: 'dateIcon', colSpan: 1, cssClass: CLASSES.formIcon, template: createFormIconTemplate('clock'), @@ -337,6 +348,7 @@ export class AppointmentForm { const { allDayExpr, startDateExpr, endDateExpr } = this.scheduler.getDataAccessors().expr; return { + name: 'allDay', itemType: 'simple', dataField: allDayExpr, cssClass: CLASSES.allDaySwitch, @@ -380,20 +392,22 @@ export class AppointmentForm { return this.createDateGroup( startDateExpr, { + name: START_DATE_GROUP_NAME, cssClass: CLASSES.startDateGroup, }, { - name: EDITOR_NAMES.startDate, + name: START_DATE_EDITOR_NAME, label: { text: messageLocalization.format('dxScheduler-editorLabelStartDate'), }, cssClass: CLASSES.startDateEditor, }, { - name: EDITOR_NAMES.startTime, + name: START_TIME_EDITOR_NAME, cssClass: CLASSES.startTimeEditor, }, { + name: 'startDateTimeZone', dataField: startDateTimeZoneExpr, cssClass: CLASSES.startDateTimeZoneEditor, editorOptions: { @@ -413,20 +427,22 @@ export class AppointmentForm { return this.createDateGroup( endDateExpr, { + name: END_DATE_GROUP_NAME, cssClass: CLASSES.endDateGroup, }, { - name: EDITOR_NAMES.endDate, + name: END_DATE_EDITOR_NAME, label: { text: messageLocalization.format('dxScheduler-editorLabelEndDate'), }, cssClass: CLASSES.endDateEditor, }, { - name: EDITOR_NAMES.endTime, + name: END_TIME_EDITOR_NAME, cssClass: CLASSES.endTimeEditor, }, { + name: 'endDateTimeZone', dataField: endDateTimeZoneExpr, cssClass: CLASSES.endDateTimeZoneEditor, }, @@ -564,6 +580,7 @@ export class AppointmentForm { private createRepeatGroup(): GroupItem { return { + name: 'repeatGroup', itemType: 'group', colCount: 2, colCountByScreen: { @@ -572,12 +589,13 @@ export class AppointmentForm { cssClass: `${CLASSES.repeatGroup} ${CLASSES.groupWithIcon}`, items: [ { + name: 'repeatIcon', colSpan: 1, cssClass: CLASSES.formIcon, template: createFormIconTemplate('repeat'), }, { - name: EDITOR_NAMES.repeat, + name: REPEAT_EDITOR_NAME, colSpan: 1, itemType: 'simple', cssClass: CLASSES.repeatEditor, @@ -608,6 +626,7 @@ export class AppointmentForm { private createDescriptionGroup(): GroupItem { return { + name: 'descriptionGroup', itemType: 'group', colCount: 2, colCountByScreen: { @@ -616,11 +635,13 @@ export class AppointmentForm { cssClass: `${CLASSES.descriptionGroup} ${CLASSES.groupWithIcon}`, items: [ { + name: 'descriptionIcon', colSpan: 1, cssClass: CLASSES.formIcon, template: createFormIconTemplate('description'), }, { + name: 'description', colSpan: 1, itemType: 'simple', cssClass: CLASSES.descriptionEditor, @@ -663,6 +684,7 @@ export class AppointmentForm { if (noCustomResourceIcons) { return { + name: RESOURCES_GROUP_NAME, itemType: 'group', visible: resourcesItems.length > 0, colCount: 2, @@ -687,9 +709,11 @@ export class AppointmentForm { resourcesItems = resourcesItems.map((item, index) => { const icon = resourcesLoaders[index].icon ?? ''; + const name = resourcesLoaders[index].resourceName ?? `resource_${index}`; return { itemType: 'group', + name: `${name}Group`, colCount: 2, colCountByScreen: { xs: 2, @@ -698,15 +722,17 @@ export class AppointmentForm { items: [ { colSpan: 1, + name: `${name}Icon`, cssClass: CLASSES.formIcon, template: createFormIconTemplate(icon), }, - item, + { ...item, name }, ], } as GroupItem; }); return { + name: RESOURCES_GROUP_NAME, itemType: 'group', colCount: 1, colCountByScreen: { @@ -758,7 +784,7 @@ export class AppointmentForm { mainGroup.addClass(CLASSES.mainHidden); recurrenceGroup.removeClass(CLASSES.recurrenceHidden); - const repeatEditorValue = this.dxForm.getEditor(EDITOR_NAMES.repeat)?.option('value'); + const repeatEditorValue = this.dxForm.getEditor(REPEAT_EDITOR_NAME)?.option('value'); this._recurrenceForm.updateRecurrenceFormValues( repeatEditorValue, @@ -787,7 +813,7 @@ export class AppointmentForm { recurrenceRuleExpr, recurrenceRule.toString() ?? undefined, ); - this.dxForm.getEditor(EDITOR_NAMES.startDate)?.option('value', recurrenceRule.startDate); + this.dxForm.getEditor(START_DATE_EDITOR_NAME)?.option('value', recurrenceRule.startDate); } } @@ -811,10 +837,10 @@ export class AppointmentForm { } private updateDateEditorsValues(): void { - const startDateEditor = this.dxForm.getEditor(EDITOR_NAMES.startDate); - const startTimeEditor = this.dxForm.getEditor(EDITOR_NAMES.startTime); - const endDateEditor = this.dxForm.getEditor(EDITOR_NAMES.endDate); - const endTimeEditor = this.dxForm.getEditor(EDITOR_NAMES.endTime); + const startDateEditor = this.dxForm.getEditor(START_DATE_EDITOR_NAME); + const startTimeEditor = this.dxForm.getEditor(START_TIME_EDITOR_NAME); + const endDateEditor = this.dxForm.getEditor(END_DATE_EDITOR_NAME); + const endTimeEditor = this.dxForm.getEditor(END_TIME_EDITOR_NAME); startDateEditor?.option('value', this.startDate); startTimeEditor?.option('value', this.startDate); @@ -823,7 +849,7 @@ export class AppointmentForm { } private updateRepeatEditor(): void { - const repeatEditor = this.dxForm.getEditor(EDITOR_NAMES.repeat); + const repeatEditor = this.dxForm.getEditor(REPEAT_EDITOR_NAME); if (!repeatEditor) { return; @@ -845,7 +871,7 @@ export class AppointmentForm { private getRepeatEditorButtons(): TextEditorButton[] { const buttons: TextEditorButton[] = []; - const repeatEditor = this.dxForm.getEditor(EDITOR_NAMES.repeat); + const repeatEditor = this.dxForm.getEditor(REPEAT_EDITOR_NAME); const selectedValue = repeatEditor?.option('value'); if (selectedValue && selectedValue !== 'never') { @@ -876,11 +902,16 @@ export class AppointmentForm { const { allDayExpr } = this.scheduler.getDataAccessors().expr; const visible = !this.formData[allDayExpr]; + const startDateItemName = `${MAIN_GROUP_NAME}.${DATE_GROUP_NAME}.${START_DATE_GROUP_NAME}.${START_DATE_EDITOR_NAME}`; + const startTimeItemName = `${MAIN_GROUP_NAME}.${DATE_GROUP_NAME}.${START_DATE_GROUP_NAME}.${START_TIME_EDITOR_NAME}`; + const endDateItemName = `${MAIN_GROUP_NAME}.${DATE_GROUP_NAME}.${END_DATE_GROUP_NAME}.${END_DATE_EDITOR_NAME}`; + const endTimeItemName = `${MAIN_GROUP_NAME}.${DATE_GROUP_NAME}.${END_DATE_GROUP_NAME}.${END_TIME_EDITOR_NAME}`; + this.dxForm.beginUpdate(); - this.dxForm.itemOption(`${MAIN_GROUP_NAME}.${EDITOR_NAMES.startDate}`, 'colSpan', visible ? 1 : 2); - this.dxForm.itemOption(`${MAIN_GROUP_NAME}.${EDITOR_NAMES.startTime}`, 'visible', visible); - this.dxForm.itemOption(`${MAIN_GROUP_NAME}.${EDITOR_NAMES.endDate}`, 'colSpan', visible ? 1 : 2); - this.dxForm.itemOption(`${MAIN_GROUP_NAME}.${EDITOR_NAMES.endTime}`, 'visible', visible); + this.dxForm.itemOption(startDateItemName, 'colSpan', visible ? 1 : 2); + this.dxForm.itemOption(startTimeItemName, 'visible', visible); + this.dxForm.itemOption(endDateItemName, 'colSpan', visible ? 1 : 2); + this.dxForm.itemOption(endTimeItemName, 'visible', visible); this.dxForm.endUpdate(); } } From bf07dd998eae8847ae34d9c8c6ca1546a7eaa5bc Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Wed, 5 Nov 2025 12:01:16 -0300 Subject: [PATCH 2/2] Fix after review --- .../m_customize_form_items.ts | 65 +++++++++---------- .../scheduler/appointment_popup/m_form.ts | 37 +++++++---- 2 files changed, 54 insertions(+), 48 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointment_popup/m_customize_form_items.ts b/packages/devextreme/js/__internal/scheduler/appointment_popup/m_customize_form_items.ts index 6a4818ddbb3f..cb3ad5499855 100644 --- a/packages/devextreme/js/__internal/scheduler/appointment_popup/m_customize_form_items.ts +++ b/packages/devextreme/js/__internal/scheduler/appointment_popup/m_customize_form_items.ts @@ -3,13 +3,11 @@ import type { FormItemComponent, GroupItem, Item as FormItem, - TabbedItem, } from '@js/ui/form'; type ConfigItem = string | FormItem; -const isGroupItem = (item: FormItem): item is GroupItem => 'items' in item && !('tabs' in item); -const isTabbedItem = (item: FormItem): item is TabbedItem => 'tabs' in item; +const isGroupItem = (item: FormItem): item is GroupItem => 'items' in item; const createFormItemFromConfig = (configItem: ConfigItem): FormItem => ( typeof configItem === 'string' @@ -19,14 +17,9 @@ const createFormItemFromConfig = (configItem: ConfigItem): FormItem => ( name: configItem, dataField: configItem, } - : { ...configItem } + : configItem ); -const getChildren = (item: FormItem): FormItem[] => [ - ...isGroupItem(item) ? item.items ?? [] : [], - ...isTabbedItem(item) ? item.tabs?.flatMap((tab) => tab.items ?? []) ?? [] : [], -]; - const buildFormItemsMap = ( items: FormItem[], map: Map = new Map(), @@ -35,7 +28,7 @@ const buildFormItemsMap = ( if (item.name) { accumulator.set(item.name, { ...item }); } - return buildFormItemsMap(getChildren(item), accumulator); + return buildFormItemsMap(isGroupItem(item) ? item.items ?? [] : [], accumulator); }, map, ); @@ -51,29 +44,14 @@ const removeItemFromGroups = ( }); }; -const getItemName = (configure: ConfigItem): string | undefined => ( - typeof configure === 'string' ? configure : configure.name +const getItemName = (customItem: ConfigItem): string | undefined => ( + typeof customItem === 'string' ? customItem : customItem.name ); -const shouldMergeWithExisting = (configure: ConfigItem): configure is FormItem => typeof configure === 'object'; - -const hasChildItems = (configure: ConfigItem): configure is GroupItem => typeof configure === 'object' -&& isGroupItem(configure) && Boolean(configure.items); - -const baseResolveItem = (map: Map) => (configure: ConfigItem): FormItem => { - const itemName = getItemName(configure); - const existingItem = itemName ? map.get(itemName) : undefined; - - if (!existingItem || !itemName) { - return createFormItemFromConfig(configure); - } - - removeItemFromGroups(itemName, map); +const shouldMergeWithExisting = (customItems: ConfigItem): customItems is FormItem => typeof customItems === 'object'; - return shouldMergeWithExisting(configure) - ? extend(true, {}, existingItem, configure) as FormItem - : existingItem; -}; +const hasChildItems = (customItems: ConfigItem): customItems is GroupItem => typeof customItems === 'object' +&& isGroupItem(customItems) && Boolean(customItems.items); const customizeFormItems = ( items: FormItem[], @@ -83,14 +61,29 @@ const customizeFormItems = ( return items; } - const map = buildFormItemsMap(items); - const resolveItem = baseResolveItem(map); + const defaultItemsMap = buildFormItemsMap(items); + + const resolveItem = (customItems: ConfigItem): FormItem => { + const itemName = getItemName(customItems); + const defaultItem = itemName ? defaultItemsMap.get(itemName) : undefined; + + if (defaultItem && itemName) { + removeItemFromGroups(itemName, defaultItemsMap); + + return shouldMergeWithExisting(customItems) + ? extend(true, {}, defaultItem, customItems) as FormItem + : defaultItem; + } + + return createFormItemFromConfig(customItems); + }; - const customize = (config: ConfigItem[]): FormItem[] => config.map((configure): FormItem => { - const formItem = resolveItem(configure); + const customize = (userItems: ConfigItem[]): + FormItem[] => userItems.map((customItems): FormItem => { + const formItem = resolveItem(customItems); - if (isGroupItem(formItem) && hasChildItems(configure) && configure.items) { - return { ...formItem, items: customize(configure.items) }; + if (isGroupItem(formItem) && hasChildItems(customItems) && customItems.items) { + return { ...formItem, items: customize(customItems.items) }; } return formItem; diff --git a/packages/devextreme/js/__internal/scheduler/appointment_popup/m_form.ts b/packages/devextreme/js/__internal/scheduler/appointment_popup/m_form.ts index e305459ba379..343e482b9bed 100644 --- a/packages/devextreme/js/__internal/scheduler/appointment_popup/m_form.ts +++ b/packages/devextreme/js/__internal/scheduler/appointment_popup/m_form.ts @@ -105,12 +105,25 @@ const DATE_GROUP_NAME = 'dateGroup'; const START_DATE_GROUP_NAME = 'startDateGroup'; const END_DATE_GROUP_NAME = 'endDateGroup'; const RESOURCES_GROUP_NAME = 'resourcesGroup'; +const SUBJECT_GROUP_NAME = 'subjectGroup'; +const REPEAT_GROUP_NAME = 'repeatGroup'; +const DESCRIPTION_GROUP_NAME = 'descriptionGroup'; const START_DATE_EDITOR_NAME = 'startDate'; const START_TIME_EDITOR_NAME = 'startTime'; const END_DATE_EDITOR_NAME = 'endDate'; const END_TIME_EDITOR_NAME = 'endTime'; const REPEAT_EDITOR_NAME = 'repeat'; +const ALL_DAY_EDITOR_NAME = 'allDay'; +const SUBJECT_EDITOR_NAME = 'subject'; +const DESCRIPTION_EDITOR_NAME = 'description'; +const START_DATE_TIMEZONE_EDITOR_NAME = 'startDateTimeZone'; +const END_DATE_TIMEZONE_EDITOR_NAME = 'endDateTimeZone'; + +const SUBJECT_ICON_NAME = 'subjectIcon'; +const DATE_ICON_NAME = 'dateIcon'; +const REPEAT_ICON_NAME = 'repeatIcon'; +const DESCRIPTION_ICON_NAME = 'descriptionIcon'; export class AppointmentForm { private readonly scheduler: any; @@ -286,7 +299,7 @@ export class AppointmentForm { const { textExpr } = this.scheduler.getDataAccessors().expr; return { - name: 'subjectGroup', + name: SUBJECT_GROUP_NAME, itemType: 'group', cssClass: `${CLASSES.subjectGroup} ${CLASSES.groupWithIcon}`, colCount: 2, @@ -295,13 +308,13 @@ export class AppointmentForm { }, items: [ { - name: 'subjectIcon', + name: SUBJECT_ICON_NAME, colSpan: 1, cssClass: CLASSES.formIcon, template: createFormIconTemplate('isnotblank'), }, { - name: 'subject', + name: SUBJECT_EDITOR_NAME, colSpan: 1, itemType: 'simple', cssClass: CLASSES.textEditor, @@ -326,7 +339,7 @@ export class AppointmentForm { }, items: [ { - name: 'dateIcon', + name: DATE_ICON_NAME, colSpan: 1, cssClass: CLASSES.formIcon, template: createFormIconTemplate('clock'), @@ -348,7 +361,7 @@ export class AppointmentForm { const { allDayExpr, startDateExpr, endDateExpr } = this.scheduler.getDataAccessors().expr; return { - name: 'allDay', + name: ALL_DAY_EDITOR_NAME, itemType: 'simple', dataField: allDayExpr, cssClass: CLASSES.allDaySwitch, @@ -407,7 +420,7 @@ export class AppointmentForm { cssClass: CLASSES.startTimeEditor, }, { - name: 'startDateTimeZone', + name: START_DATE_TIMEZONE_EDITOR_NAME, dataField: startDateTimeZoneExpr, cssClass: CLASSES.startDateTimeZoneEditor, editorOptions: { @@ -442,7 +455,7 @@ export class AppointmentForm { cssClass: CLASSES.endTimeEditor, }, { - name: 'endDateTimeZone', + name: END_DATE_TIMEZONE_EDITOR_NAME, dataField: endDateTimeZoneExpr, cssClass: CLASSES.endDateTimeZoneEditor, }, @@ -580,7 +593,7 @@ export class AppointmentForm { private createRepeatGroup(): GroupItem { return { - name: 'repeatGroup', + name: REPEAT_GROUP_NAME, itemType: 'group', colCount: 2, colCountByScreen: { @@ -589,7 +602,7 @@ export class AppointmentForm { cssClass: `${CLASSES.repeatGroup} ${CLASSES.groupWithIcon}`, items: [ { - name: 'repeatIcon', + name: REPEAT_ICON_NAME, colSpan: 1, cssClass: CLASSES.formIcon, template: createFormIconTemplate('repeat'), @@ -626,7 +639,7 @@ export class AppointmentForm { private createDescriptionGroup(): GroupItem { return { - name: 'descriptionGroup', + name: DESCRIPTION_GROUP_NAME, itemType: 'group', colCount: 2, colCountByScreen: { @@ -635,13 +648,13 @@ export class AppointmentForm { cssClass: `${CLASSES.descriptionGroup} ${CLASSES.groupWithIcon}`, items: [ { - name: 'descriptionIcon', + name: DESCRIPTION_ICON_NAME, colSpan: 1, cssClass: CLASSES.formIcon, template: createFormIconTemplate('description'), }, { - name: 'description', + name: DESCRIPTION_EDITOR_NAME, colSpan: 1, itemType: 'simple', cssClass: CLASSES.descriptionEditor,