From 6c7fc467cd1118f533ae872fc23fb86c5b0a44e1 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:34:51 +0400 Subject: [PATCH 01/16] T1333073 - DataGrid - Bound pageIndex does not update after internal page reset (#34629) --- .../m_data_controller.integration.test.ts | 88 +++++++++++++++++++ .../data_controller/data_controller.ts | 84 +++++++++--------- 2 files changed, 132 insertions(+), 40 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/m_data_controller.integration.test.ts diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/m_data_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/m_data_controller.integration.test.ts new file mode 100644 index 000000000000..cc7a35aa02c1 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/m_data_controller.integration.test.ts @@ -0,0 +1,88 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; + +import { + afterTest, beforeTest, createDataGrid, flushAsync, +} from '../../__tests__/__mock__/helpers/utils'; + +const employees = [ + { id: 1, name: 'Alice Johnson', department: 'Engineering' }, + { id: 2, name: 'Bob Smith', department: 'Engineering' }, + { id: 3, name: 'Carol White', department: 'Engineering' }, + { id: 4, name: 'Dan Brown', department: 'Sales' }, + { id: 5, name: 'Eve Davis', department: 'Sales' }, + { id: 6, name: 'Frank Miller', department: 'Sales' }, + { id: 7, name: 'Grace Wilson', department: 'HR' }, + { id: 8, name: 'Hank Moore', department: 'HR' }, + { id: 9, name: 'Ivy Taylor', department: 'Finance' }, + { id: 10, name: 'Jack Anderson', department: 'Finance' }, +]; + +describe('DataController paging.pageIndex option sync', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + it('should reset bound paging.pageIndex to 0 when a filter shrinks the page count (T1333073)', async () => { + const onOptionChanged = jest.fn(); + const { instance } = await createDataGrid({ + dataSource: employees, + keyExpr: 'id', + paging: { pageIndex: 2, pageSize: 3 }, + onOptionChanged, + }); + + expect(instance.option('paging.pageIndex')).toBe(2); + onOptionChanged.mockClear(); + + instance.filter(['department', '=', 'Engineering']); + await flushAsync(); + + expect(instance.option('paging.pageIndex')).toBe(0); + const pageIndexCall = onOptionChanged.mock.calls + .find((call) => (call[0] as { fullName: string }).fullName === 'paging.pageIndex'); + expect(pageIndexCall?.[0]).toMatchObject({ fullName: 'paging.pageIndex', value: 0 }); + }); + + it('should reset bound paging.pageIndex to 0 when the search panel shrinks the page count (T1333073)', async () => { + const onOptionChanged = jest.fn(); + const { instance } = await createDataGrid({ + dataSource: employees, + keyExpr: 'id', + paging: { pageIndex: 2, pageSize: 3 }, + searchPanel: { visible: true }, + onOptionChanged, + }); + + expect(instance.option('paging.pageIndex')).toBe(2); + onOptionChanged.mockClear(); + + instance.option('searchPanel.text', 'Alice'); + await flushAsync(); + + expect(instance.option('paging.pageIndex')).toBe(0); + const pageIndexCall = onOptionChanged.mock.calls + .find((call) => (call[0] as { fullName: string }).fullName === 'paging.pageIndex'); + expect(pageIndexCall?.[0]).toMatchObject({ fullName: 'paging.pageIndex', value: 0 }); + }); + + it('should not fire paging.pageIndex change when filtering while already on the first page (T1333073)', async () => { + const onOptionChanged = jest.fn(); + const { instance } = await createDataGrid({ + dataSource: employees, + keyExpr: 'id', + paging: { pageIndex: 0, pageSize: 3 }, + onOptionChanged, + }); + + onOptionChanged.mockClear(); + + instance.filter(['department', '=', 'Engineering']); + await flushAsync(); + + expect(instance.option('paging.pageIndex')).toBe(0); + const pageIndexCall = onOptionChanged.mock.calls + .find((call) => (call[0] as { fullName: string }).fullName === 'paging.pageIndex'); + expect(pageIndexCall).toBeUndefined(); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts index 59ad5e97a6e4..cb391a9660d4 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts @@ -49,41 +49,6 @@ import gridCoreUtils from '../m_utils'; import type { VirtualScrollController } from '../virtual_scrolling/m_virtual_scrolling_core'; import { DataHelperMixin } from './data_helper_mixin'; -const changePaging = function (that, optionName, value) { - const dataSource = that._dataSource; - - if (dataSource) { - if (value !== undefined) { - const oldValue = that._getPagingOptionValue(optionName); - if (oldValue !== value) { - that._skipProcessingPagingChange = true; - if (optionName === 'pageSize' && value === 0) { - dataSource.pageIndex(0); - that.option('paging.pageIndex', 0); - } - dataSource[optionName](value); - that.option(`paging.${optionName}`, value); - that._skipProcessingPagingChange = false; - const pageIndex = dataSource.pageIndex(); - that._isPaging = optionName === 'pageIndex'; - return dataSource[optionName === 'pageIndex' ? 'load' : 'reload']() - .done(() => { - that._isPaging = false; - that.pageChanged.fire(pageIndex); - }); - } - return Deferred().resolve().promise(); - } - return dataSource[optionName](); - } - - if (optionName === 'pageIndex' && value !== undefined) { - return Deferred().resolve().promise(); - } - - return 0; -}; - export interface HandleDataChangedArguments { changeType?: 'refresh' | 'update' | 'loadError'; isDelayed?: boolean; @@ -132,7 +97,7 @@ export class DataController extends DataHelperMixin(modules.Controller) { protected _changes!: any[]; - private readonly _skipProcessingPagingChange: boolean | undefined; + private _skipProcessingPagingChange?: boolean; private _useSortingGroupingFromColumns: boolean | undefined; @@ -1328,6 +1293,9 @@ export class DataController extends DataHelperMixin(modules.Controller) { if (dataSource) { dataSource.pageIndex(0); + if (this.option('paging.pageIndex')) { + this._silentOption('paging.pageIndex', 0); + } this._isFilterApplying = true; return this.reload().done(() => { @@ -1648,15 +1616,51 @@ export class DataController extends DataHelperMixin(modules.Controller) { return result; } + private changePaging(optionName: 'pageIndex' | 'pageSize', value?: number): any { + const dataSource = this._dataSource; + + if (!dataSource) { + return optionName === 'pageIndex' && value !== undefined + ? Deferred().resolve().promise() + : 0; + } + + if (value === undefined) { + return dataSource[optionName](); + } + + const oldValue = this._getPagingOptionValue(optionName); + if (oldValue === value) { + return Deferred().resolve().promise(); + } + + this._skipProcessingPagingChange = true; + if (optionName === 'pageSize' && value === 0) { + dataSource.pageIndex(0); + this.option('paging.pageIndex', 0); + } + dataSource[optionName](value); + this.option(`paging.${optionName}`, value); + this._skipProcessingPagingChange = false; + + const pageIndex = dataSource.pageIndex(); + this._isPaging = optionName === 'pageIndex'; + return dataSource[optionName === 'pageIndex' ? 'load' : 'reload']() + .done(() => { + this._isPaging = false; + this.pageChanged.fire(pageIndex); + }); + } + /** * @extended: virtual_scrolling */ - public pageIndex(value?) { - return changePaging(this, 'pageIndex', value); + public pageIndex(value?: number): any { + return this.changePaging('pageIndex', value); } - public pageSize(value?) { - return changePaging(this, 'pageSize', value); + public pageSize(value?: number): any { + return this.changePaging('pageSize', value); } public isCustomLoading() { From 3b35e9f6a05a97149b1fc3e8efc3a3f1ac074d7a Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov <84278206+Tucchhaa@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:01:01 +0800 Subject: [PATCH 02/16] GridCore - DataHelperMixin - fix eslint & typescript errors (#34656) Co-authored-by: Eldar Iusupzhanov --- .../data_controller/data_controller.ts | 2 +- .../data_controller/data_helper_mixin.ts | 132 +++++++++--------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts index cb391a9660d4..82909ff8374b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts @@ -218,7 +218,7 @@ export class DataController extends DataHelperMixin(modules.Controller) { this.dataErrorOccurred.add((error) => this.executeAction('onDataErrorOccurred', { error })); this._refreshDataSource(); - this.postCtor(); + this.postInit(); } /** diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_helper_mixin.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_helper_mixin.ts index 550878ac2880..c8fc65d55cb1 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_helper_mixin.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_helper_mixin.ts @@ -1,13 +1,3 @@ -// TODO: fix the rules disabled below -/* eslint-disable @stylistic/max-len */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -/* eslint-disable @typescript-eslint/init-declarations */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unused-expressions */ -/* eslint-disable no-param-reassign */ import { DataSource } from '@js/common/data/data_source/data_source'; import { normalizeDataSourceOptions } from '@js/common/data/data_source/utils'; import { extend } from '@js/core/utils/extend'; @@ -24,25 +14,33 @@ const DATA_SOURCE_FROM_URL_LOAD_MODE_METHOD = '_dataSourceFromUrlLoadMode'; const SPECIFIC_DATA_SOURCE_OPTION = '_getSpecificDataSourceOption'; const NORMALIZE_DATA_SOURCE = '_normalizeDataSource'; +type ProxiedDataSourceHandler = (...args: unknown[]) => void; + // TODO Get rid of this mixin -export const DataHelperMixin = >(Base: T) => class DataHelperMixin extends Base { - public _dataSource: any; +// eslint-disable-next-line @stylistic/max-len +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type +export const DataHelperMixin = >(Base: T) => class extends Base { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public _dataSource?: any; + + protected _dataController?: DataController; - protected _dataController: any; + protected readyWatcher?: (isLoading: boolean) => void; - protected readyWatcher: any; + // Optional hook implemented by Widget-based consumers (see Widget#_ready). + protected _ready?: (value?: boolean) => void; - private _proxiedDataSourceChangedHandler: any; + private _proxiedDataSourceChangedHandler?: ProxiedDataSourceHandler; - private _proxiedDataSourceLoadErrorHandler: any; + private _proxiedDataSourceLoadErrorHandler?: ProxiedDataSourceHandler; - private _proxiedDataSourceLoadingChangedHandler: any; + private _proxiedDataSourceLoadingChangedHandler?: ProxiedDataSourceHandler; - protected _isSharedDataSource: any; + protected _isSharedDataSource?: boolean; - private readonly _dataSourceType: any; + private readonly _dataSourceType?: () => typeof DataSource; - public postCtor() { + public postInit(): void { this.on('disposing', () => { this._disposeDataSource(); }); @@ -51,19 +49,17 @@ export const DataHelperMixin = >(Base: T) => cl /** * @extended: state_storing, virtual_scrolling */ - protected _refreshDataSource() { + protected _refreshDataSource(): void { this._initDataSource(); this._loadDataSource(); } - protected _initDataSource() { + protected _initDataSource(): void { let dataSourceOptions = SPECIFIC_DATA_SOURCE_OPTION in this - ? (this[SPECIFIC_DATA_SOURCE_OPTION] as any)() + // @ts-expect-error dynamic mixin method + ? this[SPECIFIC_DATA_SOURCE_OPTION]() : this.option('dataSource'); - let widgetDataSourceOptions; - let dataSourceType; - this._disposeDataSource(); if (dataSourceOptions) { @@ -71,22 +67,29 @@ export const DataHelperMixin = >(Base: T) => cl this._isSharedDataSource = true; this._dataSource = dataSourceOptions; } else { - widgetDataSourceOptions = DATA_SOURCE_OPTIONS_METHOD in this - ? (this[DATA_SOURCE_OPTIONS_METHOD] as any)() + const widgetDataSourceOptions = DATA_SOURCE_OPTIONS_METHOD in this + // @ts-expect-error dynamic mixin method + ? this[DATA_SOURCE_OPTIONS_METHOD]() : {}; - dataSourceType = this._dataSourceType ? this._dataSourceType() : DataSource; + const DataSourceType = this._dataSourceType + ? this._dataSourceType() + : DataSource; dataSourceOptions = normalizeDataSourceOptions(dataSourceOptions, { - fromUrlLoadMode: (DATA_SOURCE_FROM_URL_LOAD_MODE_METHOD in this) && (this[DATA_SOURCE_FROM_URL_LOAD_MODE_METHOD] as any)(), + fromUrlLoadMode: (DATA_SOURCE_FROM_URL_LOAD_MODE_METHOD in this) + // @ts-expect-error dynamic mixin method + && this[DATA_SOURCE_FROM_URL_LOAD_MODE_METHOD](), }); - // eslint-disable-next-line new-cap - this._dataSource = new dataSourceType(extend(true, {}, widgetDataSourceOptions, dataSourceOptions)); + this._dataSource = new DataSourceType( + extend(true, {}, widgetDataSourceOptions, dataSourceOptions), + ); } if (NORMALIZE_DATA_SOURCE in this) { - this._dataSource = (this[NORMALIZE_DATA_SOURCE] as any)(this._dataSource); + // @ts-expect-error dynamic mixin method + this._dataSource = this[NORMALIZE_DATA_SOURCE](this._dataSource); } this._addDataSourceHandlers(); @@ -94,7 +97,7 @@ export const DataHelperMixin = >(Base: T) => cl } } - private _initDataController() { + private _initDataController(): void { const dataController = this.option?.('_dataController'); const dataSource = this._dataSource; @@ -105,7 +108,7 @@ export const DataHelperMixin = >(Base: T) => cl } } - private _addDataSourceHandlers() { + private _addDataSourceHandlers(): void { if (DATA_SOURCE_CHANGED_METHOD in this) { this._addDataSourceChangeHandler(); } @@ -121,63 +124,59 @@ export const DataHelperMixin = >(Base: T) => cl this._addReadyWatcher(); } - private _addReadyWatcher() { - this.readyWatcher = function (isLoading) { - this._ready && this._ready(!isLoading); - }.bind(this); + private _addReadyWatcher(): void { + this.readyWatcher = (isLoading: boolean): void => { + this._ready?.(!isLoading); + }; this._dataSource.on('loadingChanged', this.readyWatcher); } - private _addDataSourceChangeHandler() { + private _addDataSourceChangeHandler(): void { const dataSource = this._dataSource; - this._proxiedDataSourceChangedHandler = function (e) { + this._proxiedDataSourceChangedHandler = (e): void => { this[DATA_SOURCE_CHANGED_METHOD](dataSource.items(), e); - }.bind(this); + }; dataSource.on('changed', this._proxiedDataSourceChangedHandler); } - private _addDataSourceLoadErrorHandler() { + private _addDataSourceLoadErrorHandler(): void { this._proxiedDataSourceLoadErrorHandler = this[DATA_SOURCE_LOAD_ERROR_METHOD].bind(this); this._dataSource.on('loadError', this._proxiedDataSourceLoadErrorHandler); } - private _addDataSourceLoadingChangedHandler() { - this._proxiedDataSourceLoadingChangedHandler = this[DATA_SOURCE_LOADING_CHANGED_METHOD].bind(this); + private _addDataSourceLoadingChangedHandler(): void { + this._proxiedDataSourceLoadingChangedHandler = this[DATA_SOURCE_LOADING_CHANGED_METHOD] + .bind(this); this._dataSource.on('loadingChanged', this._proxiedDataSourceLoadingChangedHandler); } - protected _loadDataSource() { + protected _loadDataSource(): void { const dataSource = this._dataSource; if (dataSource) { if (dataSource.isLoaded()) { - this._proxiedDataSourceChangedHandler && this._proxiedDataSourceChangedHandler(); + if (this._proxiedDataSourceChangedHandler) { + this._proxiedDataSourceChangedHandler(); + } } else { dataSource.load(); } } } - private _loadSingle(key, value) { - key = key === 'this' ? this._dataSource.key() || 'this' : key; - return this._dataSource.loadSingle(key, value); - } - - private _isLastPage() { - return !this._dataSource || this._dataSource.isLastPage() || !this._dataSource._pageSize; - } - - private _isDataSourceLoading() { - return this._dataSource && this._dataSource.isLoading(); - } - - protected _disposeDataSource() { + protected _disposeDataSource(): void { if (this._dataSource) { if (this._isSharedDataSource) { delete this._isSharedDataSource; - this._proxiedDataSourceChangedHandler && this._dataSource.off('changed', this._proxiedDataSourceChangedHandler); - this._proxiedDataSourceLoadErrorHandler && this._dataSource.off('loadError', this._proxiedDataSourceLoadErrorHandler); - this._proxiedDataSourceLoadingChangedHandler && this._dataSource.off('loadingChanged', this._proxiedDataSourceLoadingChangedHandler); + if (this._proxiedDataSourceChangedHandler) { + this._dataSource.off('changed', this._proxiedDataSourceChangedHandler); + } + if (this._proxiedDataSourceLoadErrorHandler) { + this._dataSource.off('loadError', this._proxiedDataSourceLoadErrorHandler); + } + if (this._proxiedDataSourceLoadingChangedHandler) { + this._dataSource.off('loadingChanged', this._proxiedDataSourceLoadingChangedHandler); + } if (this._dataSource._eventsStrategy) { this._dataSource._eventsStrategy.off('loadingChanged', this.readyWatcher); @@ -194,7 +193,8 @@ export const DataHelperMixin = >(Base: T) => cl } } - protected getDataSource() { - return this._dataSource || null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected getDataSource(): any | null { + return this._dataSource ?? null; } }; From 327990884555315b3383f3af37caed5dbfe1fd62 Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Wed, 29 Jul 2026 04:48:47 +0400 Subject: [PATCH 03/16] chore(demos): remove dead DisableExternalEditor metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget Gallery is dropping its in-browser code-editing feature independently of this work, so this flag has no reader left anywhere in the repo — it was purely a signal for that feature. --- apps/demos/menuMeta.json | 48 ++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/apps/demos/menuMeta.json b/apps/demos/menuMeta.json index f761c41660c8..ff52d03e04db 100644 --- a/apps/demos/menuMeta.json +++ b/apps/demos/menuMeta.json @@ -265,8 +265,7 @@ ], "MvcDocUrl": "https://docs.devexpress.com/DevExtremeAspNetMvc/400704/concepts/data-binding", "NetCoreDocUrl": "https://docs.devexpress.com/AspNetCore/400575/concepts/devextreme-based-controls/data-binding", - "DemoType": "Web", - "DisableExternalEditor": true + "DemoType": "Web" }, { "Title": "SignalR Service", @@ -469,8 +468,7 @@ "/Models/SampleData/CustomEditorsTasks.cs", "/Scripts/data/statuses.js" ], - "DemoType": "Web", - "DisableExternalEditor": true + "DemoType": "Web" }, { "Title": "Data Validation", @@ -486,8 +484,7 @@ "MvcDocUrl": "https://docs.devexpress.com/DevExtremeAspNetMvc/400705/concepts/client-side-data-validation#overview", "NetCoreDocUrl": "https://docs.devexpress.com/AspNetCore/400576/concepts/devextreme-based-controls/client-side-data-validation#overview", "DemoType": "Web", - "Modules": "devextreme-aspnet-data-nojquery", - "DisableExternalEditor": true + "Modules": "devextreme-aspnet-data-nojquery" }, { "Title": "Cascading Lookups", @@ -524,8 +521,7 @@ "BackendFiles": [ "DataGridCollaborativeEditingController.cs", "DataGridCollaborativeEditingHub.cs" - ], - "DisableExternalEditor": true + ] }, { "Title": "Remote CRUD Operations", @@ -560,8 +556,7 @@ "BackendFiles": [ "DataGridBatchUpdateWebApiController.cs", "InMemoryNorthwindContext.cs" - ], - "DisableExternalEditor": true + ] }, { "Title": "Edit State Management", @@ -585,8 +580,7 @@ "NetCoreDescription": "", "MvcAdditionalFiles": [], "Modules": "devextreme-aspnet-data-nojquery", - "DemoType": "Web", - "DisableExternalEditor": true + "DemoType": "Web" } ] }, @@ -1329,8 +1323,7 @@ ], "MvcDocUrl": "https://docs.devexpress.com/DevExtremeAspNetMvc/400704/concepts/data-binding", "NetCoreDocUrl": "https://docs.devexpress.com/AspNetCore/400575/concepts/devextreme-based-controls/data-binding", - "DemoType": "Web", - "DisableExternalEditor": true + "DemoType": "Web" } ] }, @@ -1705,8 +1698,7 @@ "MvcDescription": "", "NetCoreDescription": "", "MvcAdditionalFiles": [], - "DemoType": "Web", - "DisableExternalEditor": true + "DemoType": "Web" } ] }, @@ -1780,8 +1772,7 @@ "MvcDescription": "", "NetCoreDescription": "", "MvcAdditionalFiles": [], - "DemoType": "Web", - "DisableExternalEditor": true + "DemoType": "Web" } ] }, @@ -3555,8 +3546,7 @@ "BackendFiles": [ "DiagramEmployeesController.cs", "InMemoryEmployeesDataContext.cs" - ], - "DisableExternalEditor": true + ] } ] }, @@ -3759,8 +3749,7 @@ "SchedulerDataController.cs", "InMemoryAppointmentsDataContext" ], - "DemoType": "Web", - "DisableExternalEditor": true + "DemoType": "Web" }, { "Title": "SignalR Service", @@ -3778,8 +3767,7 @@ "BackendFiles": [ "SchedulerSignalRController.cs", "SchedulerSignalRHub.cs" - ], - "DisableExternalEditor": true + ] }, { "Title": "Google Calendar Integration", @@ -5498,8 +5486,7 @@ "Widget": "FileUploader", "BackendFiles": [ "FileUploaderController.cs" - ], - "DisableExternalEditor": true + ] }, { "Title": "Validation", @@ -5508,8 +5495,7 @@ "Widget": "FileUploader", "BackendFiles": [ "FileUploaderController.cs" - ], - "DisableExternalEditor": true + ] }, { "Title": "Chunk Upload", @@ -5527,8 +5513,7 @@ "Title": "Custom Drop Zone", "Name": "CustomDropzone", "DocUrl": "", - "Widget": "FileUploader", - "DisableExternalEditor": true + "Widget": "FileUploader" } ] } @@ -6736,8 +6721,7 @@ ], "MvcDocUrl": "https://docs.devexpress.com/DevExtremeAspNetMvc/400706/concepts/localization#globalize", "NetCoreDocUrl": "https://docs.devexpress.com/AspNetCore/400577/concepts/devextreme-based-controls/localization#globalize", - "DemoType": "Web", - "DisableExternalEditor": true + "DemoType": "Web" } ] } From b7de53eb69621acb5ee8ba3d82a7bf83c15a07b8 Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Wed, 29 Jul 2026 04:51:23 +0400 Subject: [PATCH 04/16] feat(demos): add esbuild-based in-place demo build infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces SystemJS's per-request in-browser transpile with real esbuild bundling, reusing the existing CSP-check scripts as the shared core rather than duplicating them: - csp-bundle.js / csp-bundle-angular.js: parameterized to write bundle.js/bundle.css next to a demo's own source (BUNDLE_IN_PLACE) instead of only to the CSP-check's csp-bundled-demos/ side directory. Also fixes two bugs found while wiring this up: a missing require.main guard that made requiring csp-bundle.js trigger a full bundle-everything run, and the Angular AOT compiler plugin sometimes listing a CSS output in its esbuild metafile that it never actually writes to disk. - utils/build/{build-react-vue-demo,build-angular-demo}.js: thin wrappers that drive the above for a single demo. - utils/shell/server.js: lazy build-on-request — rebuilds only the demo being viewed when its source is newer than its bundle, so local dev doesn't need a pre-build/watch step over ~2,500 demos. - utils/templates/{Angular,React,Vue}/index.html: scaffold templates for new demos, updated to the - - - - -
Loading...
+ diff --git a/apps/demos/utils/templates/React/index.html b/apps/demos/utils/templates/React/index.html index a8e5ed3329d8..12fd54cc7f5e 100644 --- a/apps/demos/utils/templates/React/index.html +++ b/apps/demos/utils/templates/React/index.html @@ -5,19 +5,14 @@ - - - - - - + +
+ diff --git a/apps/demos/utils/templates/Vue/index.html b/apps/demos/utils/templates/Vue/index.html index 9d70f08217c0..12fd54cc7f5e 100644 --- a/apps/demos/utils/templates/Vue/index.html +++ b/apps/demos/utils/templates/Vue/index.html @@ -5,24 +5,14 @@ - - - - - - - + +
+ From 023c9e7da319d790c0626ae038b8dc702e44890f Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Wed, 29 Jul 2026 04:52:15 +0400 Subject: [PATCH 05/16] ci(demos): point demo CI at the esbuild build instead of SystemJS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - visual-tests-demos.yml: build-demos becomes a matrix job (React/Vue single-shard, Angular 3-shard, mirroring csp-check-frameworks' already-proven split) using BUNDLE_IN_PLACE=1 instead of the old `nx prepare-bundles` vendor-bundle step; testcafe-frameworks-all/ -changed download the new per-framework/-shard artifacts and overlay them onto their own checkout instead of the old single vendor bundle; csp-check-frameworks/csp-check-jquery drop the now-moot CSP_USE_BUNDLED env (every demo is bundled now, there's no SystemJS dev mode left to compare against). - csp-server.js: Demos/ is unconditionally the bundled, strict-CSP source now, so the whole SystemJS-dev-vs-csp-bundled-demos duality (relaxed nonce/strict-dynamic/unsafe-eval script-src, Vue's unsafe-inline style-src default, the SystemJS-only font-src allowlist entries) collapses to one profile; the csp-bundled-demos/ routes/handler are dropped as dead code. - csp-check.js: same simplification — always reads from Demos/. Not yet run in real CI — drafted and diff-reviewed locally, since this repo's demo CI can't be exercised outside GitHub Actions. --- .github/workflows/visual-tests-demos.yml | 130 ++++++++++++++++++----- apps/demos/utils/server/csp-check.js | 10 +- apps/demos/utils/server/csp-server.js | 123 ++++----------------- 3 files changed, 127 insertions(+), 136 deletions(-) diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index 1b407c0de197..317189a678ce 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -274,8 +274,8 @@ jobs: retention-days: 1 build-demos: + name: ${{ matrix.SHARD_TOTAL == 1 && format('Build demos ({0})', matrix.FRAMEWORK) || format('Build demos ({0} {1}/{2})', matrix.FRAMEWORK, matrix.SHARD_INDEX, matrix.SHARD_TOTAL) }} runs-on: devextreme-shr2 - name: Build Demos Bundles timeout-minutes: 30 needs: [check-should-run, determine-framework-tests-scope, build-devextreme] if: | @@ -286,6 +286,20 @@ jobs: needs.build-devextreme.result == 'success' env: NODE_OPTIONS: --max-old-space-size=8192 + strategy: + fail-fast: false + # Demos now bundle with esbuild, in place, one demo at a time — no more + # SystemJS vendor bundle to build. Angular's AOT compile is the slow + # part, so it's split into parallel shards (same round-robin scheme + # csp-bundle-angular.js already uses for the CSP check below); + # React/Vue stay light enough for a single shard. + matrix: + include: + - { FRAMEWORK: React, SHARD_INDEX: 1, SHARD_TOTAL: 1 } + - { FRAMEWORK: Vue, SHARD_INDEX: 1, SHARD_TOTAL: 1 } + - { FRAMEWORK: Angular, SHARD_INDEX: 1, SHARD_TOTAL: 3 } + - { FRAMEWORK: Angular, SHARD_INDEX: 2, SHARD_TOTAL: 3 } + - { FRAMEWORK: Angular, SHARD_INDEX: 3, SHARD_TOTAL: 3 } steps: - name: Get sources @@ -331,20 +345,26 @@ jobs: # - name: Link wrappers packages # run: pnpm install --frozen-lockfile - - name: Prepare bundles + - name: Build demo bundles working-directory: apps/demos - run: pnpm exec nx prepare-bundles + env: + BUNDLE_IN_PLACE: '1' + CSP_SHARD_INDEX: ${{ matrix.SHARD_INDEX }} + CSP_SHARD_TOTAL: ${{ matrix.SHARD_TOTAL }} + run: node utils/server/csp-bundle.js --framework=${{ matrix.FRAMEWORK }} - name: Demos - Run tsc + if: matrix.FRAMEWORK == 'React' && matrix.SHARD_INDEX == 1 # global check, only needs to run once working-directory: apps/demos run: pnpm exec tsc --noEmit - name: Copy build artifacts uses: actions/upload-artifact@v7 with: - name: devextreme-bundles + name: devextreme-bundles-${{ matrix.FRAMEWORK }}${{ matrix.SHARD_TOTAL != 1 && format('-shard{0}', matrix.SHARD_INDEX) || '' }} path: | - apps/demos/bundles/ + apps/demos/Demos/**/bundle.js + apps/demos/Demos/**/bundle.css retention-days: 1 lint: @@ -913,18 +933,47 @@ jobs: working-directory: apps/demos run: pnpm run prepare-js - - name: Update bundles config - working-directory: apps/demos - run: pnpm run update-config + # Demo bundles are built once per framework/shard in build-demos and + # downloaded here to overlay onto this job's own checkout (same commit), + # landing back at Demos////bundle.{js,css} — + # exactly where each demo's own index.html already expects them. + - name: Download demo bundles (React) + if: startsWith(matrix.CONSTEL, 'react') + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles-React + path: apps/demos + + - name: Download demo bundles (Vue) + if: startsWith(matrix.CONSTEL, 'vue') + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles-Vue + path: apps/demos - - name: Create bundles dir - run: mkdir -p apps/demos/bundles + # Angular is built in 3 shards (see build-demos) but tested in 10 — every + # Angular test shard needs all 3 build shards since the two shard schemes + # don't line up demo-for-demo. + - name: Download demo bundles (Angular shard 1) + if: startsWith(matrix.CONSTEL, 'angular') + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles-Angular-shard1 + path: apps/demos + + - name: Download demo bundles (Angular shard 2) + if: startsWith(matrix.CONSTEL, 'angular') + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles-Angular-shard2 + path: apps/demos - - name: Download bundles artifacts + - name: Download demo bundles (Angular shard 3) + if: startsWith(matrix.CONSTEL, 'angular') uses: actions/download-artifact@v8 with: - name: devextreme-bundles - path: apps/demos/bundles + name: devextreme-bundles-Angular-shard3 + path: apps/demos - name: Run Web Server run: | @@ -1049,18 +1098,45 @@ jobs: working-directory: apps/demos run: pnpm run prepare-js - - name: Update bundles config - working-directory: apps/demos - run: pnpm run update-config + # Demo bundles are built once per framework/shard in build-demos and + # downloaded here to overlay onto this job's own checkout (same commit), + # landing back at Demos////bundle.{js,css} — + # exactly where each demo's own index.html already expects them. + - name: Download demo bundles (React) + if: matrix.CONSTEL == 'react' + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles-React + path: apps/demos - - name: Create bundles dir - run: mkdir -p apps/demos/bundles + - name: Download demo bundles (Vue) + if: matrix.CONSTEL == 'vue' + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles-Vue + path: apps/demos - - name: Download bundles artifacts + # build-demos shards Angular 3 ways regardless of how it's tested here. + - name: Download demo bundles (Angular shard 1) + if: matrix.CONSTEL == 'angular' uses: actions/download-artifact@v8 with: - name: devextreme-bundles - path: apps/demos/bundles + name: devextreme-bundles-Angular-shard1 + path: apps/demos + + - name: Download demo bundles (Angular shard 2) + if: matrix.CONSTEL == 'angular' + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles-Angular-shard2 + path: apps/demos + + - name: Download demo bundles (Angular shard 3) + if: matrix.CONSTEL == 'angular' + uses: actions/download-artifact@v8 + with: + name: devextreme-bundles-Angular-shard3 + path: apps/demos - name: Download changes artifacts uses: actions/download-artifact@v8 @@ -1163,8 +1239,6 @@ jobs: needs.build-devextreme.result == 'success' runs-on: devextreme-shr2 timeout-minutes: 60 - env: - CSP_USE_BUNDLED: '0' steps: - name: Get sources @@ -1251,8 +1325,6 @@ jobs: - { FRAMEWORK: Angular, SHARD_INDEX: 3, SHARD_TOTAL: 3 } runs-on: devextreme-shr2 timeout-minutes: 60 - env: - CSP_USE_BUNDLED: '1' steps: - name: Get sources @@ -1296,12 +1368,14 @@ jobs: working-directory: apps/demos run: pnpm add --ignore-workspace --allow-build=core-js --allow-build=inferno devextreme-aspnet-data@5.1.0 devextreme-aspnet-data-nojquery@5.1.0 ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz && rm -f pnpm-workspace.yaml pnpm-lock.yaml - # Bundle production-style pages only when the CSP check is configured to - # read csp-bundled-demos; otherwise the checker uses SystemJS dev demos. + # Every demo is a plain esbuild bundle now, so this is the same build + # step build-demos runs — done again here (independently, in parallel) + # rather than depending on that job, to keep this check's timing + # decoupled from the rest of the pipeline. - name: Bundle demos for CSP check - if: env.CSP_USE_BUNDLED == '1' || env.CSP_USE_BUNDLED == 'true' working-directory: apps/demos env: + BUNDLE_IN_PLACE: '1' CSP_SHARD_INDEX: ${{ matrix.SHARD_INDEX }} CSP_SHARD_TOTAL: ${{ matrix.SHARD_TOTAL }} run: node utils/server/csp-bundle.js --framework=${{ matrix.FRAMEWORK }} diff --git a/apps/demos/utils/server/csp-check.js b/apps/demos/utils/server/csp-check.js index be5661116fd3..97d01d61384b 100644 --- a/apps/demos/utils/server/csp-check.js +++ b/apps/demos/utils/server/csp-check.js @@ -11,16 +11,12 @@ const REPORT_DIR = join(DEMO_ROOT, 'csp-reports'); const SERVER_URL = process.env.CSP_SERVER_URL || 'http://localhost:8080'; const FRAMEWORK = (process.env.CSP_FRAMEWORKS || 'jQuery').trim(); -// Use pre-built bundles from csp-bundle.js instead of the SystemJS dev demos. -const USE_BUNDLED = process.env.CSP_USE_BUNDLED === '1' || process.env.CSP_USE_BUNDLED === 'true'; - const CORES = (typeof os.availableParallelism === 'function' ? os.availableParallelism() : (os.cpus() || []).length) || 1; function defaultConcurrency() { if (FRAMEWORK === 'jQuery') return Math.max(6, Math.min(8, CORES)); - if (USE_BUNDLED) return Math.max(2, Math.min(8, CORES)); - return 2; + return Math.max(2, Math.min(8, CORES)); } const DEFAULT_CONCURRENCY = defaultConcurrency(); const parsedConcurrency = parseInt(process.env.CSP_CONCURRENCY, 10); @@ -171,7 +167,7 @@ async function waitForDebugger(port, maxWaitMs = 15000) { } function findDemos() { - const demosDirName = USE_BUNDLED ? 'csp-bundled-demos' : 'Demos'; + const demosDirName = 'Demos'; const demosDir = join(DEMO_ROOT, demosDirName); const result = []; @@ -374,7 +370,7 @@ async function main() { console.log(`Chrome: ${CHROME_PATH}`); console.log(`Server: ${SERVER_URL}`); console.log(`Framework: ${FRAMEWORK}`); - console.log(`Source: ${USE_BUNDLED ? 'csp-bundled-demos (production-style)' : 'Demos (SystemJS dev)'}`); + console.log('Source: Demos (esbuild-bundled)'); console.log(`Concurrency: ${CONCURRENCY}\n`); const demos = findDemos(); diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index 5b2243644fd6..c9acc399d90b 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -221,105 +221,43 @@ const CSP_FRAMEWORK_ALLOWLIST = { }, }, Angular: { - FilterBuilder: { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, // globalize/message.js uses new Function() internally 'Localization/UsingGlobalize': { 'script-src': ["'unsafe-eval'"] }, }, React: { - 'Slider/Overview': { - 'font-src': ['https://maxcdn.bootstrapcdn.com'], - }, - 'Sortable/Customization': { - 'font-src': ['https://maxcdn.bootstrapcdn.com'], - }, - 'TagBox/Grouping': { - 'font-src': ['https://maxcdn.bootstrapcdn.com'], - }, - 'SelectBox/Grouping': { - 'font-src': ['https://maxcdn.bootstrapcdn.com'], - }, - 'SelectBox/SearchAndEditing': { - 'font-src': ['https://maxcdn.bootstrapcdn.com'], - }, - Calendar: { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, - CheckBox: { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, - 'DateRangeBox/Overview': { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, - Form: { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, - 'Lookup/Templates': { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, // globalize/message.js uses new Function() internally 'Localization/UsingGlobalize': { 'script-src': ["'unsafe-eval'"] }, }, Vue: { - 'SelectBox/Grouping': { - 'font-src': ['https://maxcdn.bootstrapcdn.com'], - }, - Calendar: { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, - CheckBox: { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, - Form: { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, - 'Lookup/Templates': { 'font-src': ['https://maxcdn.bootstrapcdn.com'] }, // globalize/message.js uses new Function() internally 'Localization/UsingGlobalize': { 'script-src': ["'unsafe-eval'"] }, }, }; -// Vue's SFC loader injects component CSS as inline