diff --git a/.github/actions/setup-chrome/action.yml b/.github/actions/setup-chrome/action.yml index f6272e475988..a1d9d1349f7e 100644 --- a/.github/actions/setup-chrome/action.yml +++ b/.github/actions/setup-chrome/action.yml @@ -29,6 +29,8 @@ runs: CHROME_VERSION: ${{ inputs.chrome-version }} run: | if [ -n "$CHROME_VERSION" ]; then + sudo apt-get update + sudo apt-get install libu2f-udev curl -L "https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}-1_amd64.deb" > /tmp/chrome.deb sudo dpkg -i /tmp/chrome.deb unlink /tmp/chrome.deb diff --git a/.github/workflows/_security-alerts.yml b/.github/workflows/_security-alerts.yml index 9821efb09ac7..daa320f801c6 100644 --- a/.github/workflows/_security-alerts.yml +++ b/.github/workflows/_security-alerts.yml @@ -18,7 +18,7 @@ jobs: run: | RESPONSE=$(curl \ -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.TOKEN }}" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open) echo 'ALERTS<> $GITHUB_OUTPUT echo $RESPONSE >> $GITHUB_OUTPUT diff --git a/.github/workflows/testcafe_tests.yml b/.github/workflows/testcafe_tests.yml index 4511c466443d..0a8b0b69355f 100644 --- a/.github/workflows/testcafe_tests.yml +++ b/.github/workflows/testcafe_tests.yml @@ -138,6 +138,9 @@ jobs: { componentFolder: "filterBuilder", name: "filterBuilder" }, { componentFolder: "filterBuilder", name: "filterBuilder - material", theme: 'material.blue.light' }, { componentFolder: "filterBuilder", name: "filterBuilder - fluent", theme: 'fluent.blue.light' }, + { componentFolder: "pager", name: "pager" }, + { componentFolder: "pager", name: "pager - material", theme: 'material.blue.light' }, + { componentFolder: "pager", name: "pager - fluent", theme: 'fluent.blue.light' }, ] runs-on: devextreme-shr2 timeout-minutes: 90 diff --git a/apps/demos/configs/Angular/config.js b/apps/demos/configs/Angular/config.js index cbad525f76a0..2d1e7e43600e 100644 --- a/apps/demos/configs/Angular/config.js +++ b/apps/demos/configs/Angular/config.js @@ -174,6 +174,7 @@ window.config = { /* devextreme-angular umd maps */ 'devextreme-angular': 'bundles:devextreme-angular/devextreme-angular.umd.js', 'devextreme-angular/core': 'bundles:devextreme-angular/devextreme-angular-core.umd.js', + 'devextreme-angular/http': 'bundles:devextreme-angular/devextreme-angular-http.umd.js', ...componentNames.reduce((acc, name) => { acc[`devextreme-angular/ui/${name}`] = `bundles:devextreme-angular/devextreme-angular-ui-${name}.umd.js`; return acc; diff --git a/apps/demos/rollup.devextreme-angular.umd.config.mjs b/apps/demos/rollup.devextreme-angular.umd.config.mjs index 4c6cfec8be7b..f15ea55bbc80 100644 --- a/apps/demos/rollup.devextreme-angular.umd.config.mjs +++ b/apps/demos/rollup.devextreme-angular.umd.config.mjs @@ -10,13 +10,14 @@ const componentNames = fs.readdirSync(baseDir) .map((fileName) => fileName.replace('.mjs.map', '')); const inputs = { - 'devextreme-angular-core': `${baseDir}devextreme-angular-core.mjs`, - 'devextreme-angular': `${baseDir}devextreme-angular.mjs`, - ...componentNames.reduce((acc, name) => { - acc[name] = `${baseDir}${name}.mjs`; + 'devextreme-angular': `${baseDir}devextreme-angular.mjs`, + 'devextreme-angular-core': `${baseDir}devextreme-angular-core.mjs`, + 'devextreme-angular-http': `${baseDir}devextreme-angular-http.mjs`, + ...componentNames.reduce((acc, name) => { + acc[name] = `${baseDir}${name}.mjs`; - return acc; - }, {}), + return acc; + }, {}), }; const getLibName = (file) => file diff --git a/apps/demos/testing/common.test.js b/apps/demos/testing/common.test.js index 8db3dc6f2813..d9218dd73661 100644 --- a/apps/demos/testing/common.test.js +++ b/apps/demos/testing/common.test.js @@ -66,9 +66,6 @@ const SKIPPED_TESTS = { { demo: 'TaskTemplate', themes: [THEME.generic, THEME.material, THEME.fluent] }, { demo: 'Validation', themes: [THEME.generic, THEME.material, THEME.fluent] }, ], - DataGrid: [ - { demo: 'RightToLeftSupport', themes: [THEME.generic, THEME.material, THEME.fluent] }, - ], }, Angular: { DataGrid: [ @@ -79,7 +76,6 @@ const SKIPPED_TESTS = { { demo: 'CellEditingAndEditingAPI', themes: [THEME.material] }, { demo: 'MultipleRecordSelectionAPI', themes: [THEME.material] }, { demo: 'RemoteGrouping', themes: [THEME.generic] }, - { demo: 'RightToLeftSupport', themes: [THEME.generic, THEME.material, THEME.fluent] }, ], Charts: [ { demo: 'Overview', themes: [THEME.material] }, @@ -147,7 +143,6 @@ const SKIPPED_TESTS = { { demo: 'ToolbarCustomization', themes: [THEME.fluent, THEME.material] }, { demo: 'MultipleRecordSelectionAPI', themes: [THEME.material] }, { demo: 'CellEditingAndEditingAPI', themes: [THEME.material] }, - { demo: 'RightToLeftSupport', themes: [THEME.generic, THEME.material, THEME.fluent] }, ], Gantt: [ { demo: 'Validation', themes: [THEME.generic, THEME.material, THEME.fluent] }, @@ -210,7 +205,6 @@ const SKIPPED_TESTS = { { demo: 'CellEditingAndEditingAPI', themes: [THEME.material] }, { demo: 'PopupEditing', themes: [THEME.generic] }, { demo: 'RecordPaging', themes: [THEME.generic] }, - { demo: 'RightToLeftSupport', themes: [THEME.generic, THEME.material, THEME.fluent] }, ], FieldSet: [ { demo: 'Overview', themes: [THEME.fluent] }, diff --git a/apps/demos/testing/etalons/Gantt-FilterRow (fluent.blue.light).png b/apps/demos/testing/etalons/Gantt-FilterRow (fluent.blue.light).png index 085ff7e05852..7d5ceaf5e9d7 100644 Binary files a/apps/demos/testing/etalons/Gantt-FilterRow (fluent.blue.light).png and b/apps/demos/testing/etalons/Gantt-FilterRow (fluent.blue.light).png differ diff --git a/apps/demos/testing/etalons/Gantt-FilterRow (material.blue.light).png b/apps/demos/testing/etalons/Gantt-FilterRow (material.blue.light).png index 4365941637ff..0cf1ac1739f7 100644 Binary files a/apps/demos/testing/etalons/Gantt-FilterRow (material.blue.light).png and b/apps/demos/testing/etalons/Gantt-FilterRow (material.blue.light).png differ diff --git a/apps/demos/testing/etalons/Gantt-FilterRow.png b/apps/demos/testing/etalons/Gantt-FilterRow.png index af9bb738e9b5..50eebad8e17a 100644 Binary files a/apps/demos/testing/etalons/Gantt-FilterRow.png and b/apps/demos/testing/etalons/Gantt-FilterRow.png differ diff --git a/apps/demos/testing/etalons/Scheduler-Adaptability (fluent.blue.light).png b/apps/demos/testing/etalons/Scheduler-Adaptability (fluent.blue.light).png index e1a92f5ca0fc..0769d4eb0fd7 100644 Binary files a/apps/demos/testing/etalons/Scheduler-Adaptability (fluent.blue.light).png and b/apps/demos/testing/etalons/Scheduler-Adaptability (fluent.blue.light).png differ diff --git a/apps/demos/testing/etalons/Scheduler-Adaptability (material.blue.light).png b/apps/demos/testing/etalons/Scheduler-Adaptability (material.blue.light).png index 71b5fc1d4481..5da868060b37 100644 Binary files a/apps/demos/testing/etalons/Scheduler-Adaptability (material.blue.light).png and b/apps/demos/testing/etalons/Scheduler-Adaptability (material.blue.light).png differ diff --git a/apps/demos/testing/etalons/Scheduler-Adaptability.png b/apps/demos/testing/etalons/Scheduler-Adaptability.png index f64b8b8b0561..c457999f5e6c 100644 Binary files a/apps/demos/testing/etalons/Scheduler-Adaptability.png and b/apps/demos/testing/etalons/Scheduler-Adaptability.png differ diff --git a/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell (fluent.blue.light).png b/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell (fluent.blue.light).png index 009e1f620025..59324a0f5581 100644 Binary files a/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell (fluent.blue.light).png and b/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell (fluent.blue.light).png differ diff --git a/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell (material.blue.light).png b/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell (material.blue.light).png index de914a54fad7..feaf36eaaa22 100644 Binary files a/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell (material.blue.light).png and b/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell (material.blue.light).png differ diff --git a/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell.png b/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell.png index 7f5836b3816c..579952cd8a33 100644 Binary files a/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell.png and b/apps/demos/testing/etalons/Scheduler-LimitAppointmentCountPerCell.png differ diff --git a/apps/demos/testing/widgets/scheduler/etalons/scheduler_custom-dnd_list_all-day-panel (fluent.blue.light).png b/apps/demos/testing/widgets/scheduler/etalons/scheduler_custom-dnd_list_all-day-panel (fluent.blue.light).png index 4fbd735b0d51..70d2eb437c9c 100644 Binary files a/apps/demos/testing/widgets/scheduler/etalons/scheduler_custom-dnd_list_all-day-panel (fluent.blue.light).png and b/apps/demos/testing/widgets/scheduler/etalons/scheduler_custom-dnd_list_all-day-panel (fluent.blue.light).png differ diff --git a/apps/demos/testing/widgets/scheduler/etalons/scheduler_custom-dnd_list_all-day-panel (material.blue.light).png b/apps/demos/testing/widgets/scheduler/etalons/scheduler_custom-dnd_list_all-day-panel (material.blue.light).png index 5f1dcdea2400..e43cddec5d9c 100644 Binary files a/apps/demos/testing/widgets/scheduler/etalons/scheduler_custom-dnd_list_all-day-panel (material.blue.light).png and b/apps/demos/testing/widgets/scheduler/etalons/scheduler_custom-dnd_list_all-day-panel (material.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/focus/focus.ts b/e2e/testcafe-devextreme/tests/dataGrid/focus/focus.ts index 4ab8d20b809d..7d038a7ac5f2 100644 --- a/e2e/testcafe-devextreme/tests/dataGrid/focus/focus.ts +++ b/e2e/testcafe-devextreme/tests/dataGrid/focus/focus.ts @@ -1,5 +1,6 @@ import DataGrid from 'devextreme-testcafe-models/dataGrid'; import { ClientFunction } from 'testcafe'; +import FilterTextBox from 'devextreme-testcafe-models/dataGrid/editors/filterTextBox'; import { createWidget } from '../../../helpers/createWidget'; import url from '../../../helpers/getPageUrl'; @@ -148,3 +149,31 @@ test('Should remove dx-focused class on blur event from the cell', async (t) => })(); }); }); + +test('DataGrid - FilterRow cell loses focus when focusedRowEnabled is true and editing is in batch mode (T1246926)', async (t) => { + const dataGrid = new DataGrid('#container'); + const filterEditor = dataGrid.getFilterEditor(0, FilterTextBox).getInput(); + + await t + .click(dataGrid.getDataCell(0, 0).element) + .click(filterEditor) + .expect(filterEditor.focused) + .ok(); +}).before(async () => createWidget('dxDataGrid', { + dataSource: [ + { + ID: 1, + FirstName: 'John', + }, + ], + keyExpr: 'ID', + filterRow: { + visible: true, + }, + focusedRowEnabled: true, + editing: { + mode: 'batch', + allowUpdating: true, + }, + columns: ['FirstName'], +})); diff --git a/e2e/testcafe-devextreme/tests/dataGrid/pager.ts b/e2e/testcafe-devextreme/tests/dataGrid/pager.ts index dca040279413..1fb0156b2468 100644 --- a/e2e/testcafe-devextreme/tests/dataGrid/pager.ts +++ b/e2e/testcafe-devextreme/tests/dataGrid/pager.ts @@ -36,7 +36,7 @@ safeSizeTest('Full size pager', async (t) => { .ok('page size 5 selected') .expect(pager.getNavPage('6').selected) .ok('page 6 selected') - .expect(pager.infoText.textContent) + .expect(pager.getInfoText().textContent) .eql('Page 6 of 20 (100 items)') .expect(dataGrid.getDataCell(29, 2).element.textContent) .eql('29'); @@ -50,17 +50,17 @@ safeSizeTest('Full size pager', async (t) => { .click(pager.getNavPage('7').element) .expect(dataGrid.getDataCell(10 * 7 - 1, 2).element.textContent) .eql('69') - .expect(pager.infoText.textContent) + .expect(pager.getInfoText().textContent) .eql('Page 7 of 10 (100 items)'); // navigate to prev page (6) await t .click(pager.getPrevNavButton().element) - .expect(pager.infoText.textContent) + .expect(pager.getInfoText().textContent) .eql('Page 6 of 10 (100 items)'); // navigate to next page (7) await t .click(pager.getNextNavButton().element) - .expect(pager.infoText.textContent) + .expect(pager.getInfoText().textContent) .eql('Page 7 of 10 (100 items)') .expect(await compareScreenshot(t, 'pager-full-allpages.png')) .ok(); diff --git a/e2e/testcafe-devextreme/tests/dataGrid/selection.ts b/e2e/testcafe-devextreme/tests/dataGrid/selection.ts index eafd3ce2352c..c2bbe91b6ce1 100644 --- a/e2e/testcafe-devextreme/tests/dataGrid/selection.ts +++ b/e2e/testcafe-devextreme/tests/dataGrid/selection.ts @@ -155,3 +155,96 @@ test('Select rows by shift should work when grid has real time updates', async ( pageSize: 10, }, })); + +// --- T1234676 --- + +const data = [ + { ID: 'aaa', Name: 'Name 1' }, + { ID: 'AAA', Name: 'Name 2' }, + { ID: 'BBB', Name: 'Name 3' }, +]; +const DATA_GRID_SELECTOR = '#container'; +(['base', undefined] as ('base' | undefined)[]).forEach((sensitivity) => { + test(`Deferred selection should work correctly with deferred sensitivity: ${sensitivity}`, async (t) => { + const dataGrid = new DataGrid(DATA_GRID_SELECTOR); + const checkBoxCell = dataGrid.getDataCell(0, 0); + const firstRow = dataGrid.getDataRow(0); + const secondRow = dataGrid.getDataRow(1); + + await t.click(checkBoxCell.element); + + await t + .expect(firstRow.isSelected).ok() + .expect(secondRow.isSelected).ok(); + }).before(() => createWidget('dxDataGrid', { + dataSource: data, + keyExpr: 'ID', + columns: ['ID', 'Name'], + showBorders: true, + selection: { + mode: 'multiple', + deferred: true, + sensitivity, + }, + })); +}); + +test('Deferred selection should work correctly with deferred sensitivity: \'case\'', async (t) => { + const dataGrid = new DataGrid(DATA_GRID_SELECTOR); + const checkBoxCell = dataGrid.getDataCell(0, 0); + const firstRow = dataGrid.getDataRow(0); + const secondRow = dataGrid.getDataRow(1); + + await t.click(checkBoxCell.element); + + await t + .expect(firstRow.isSelected).ok() + .expect(secondRow.isSelected).notOk(); +}).before(() => createWidget('dxDataGrid', { + dataSource: data, + keyExpr: 'ID', + columns: ['ID', 'Name'], + showBorders: true, + selection: { + mode: 'multiple', + deferred: true, + sensitivity: 'case', + }, +})); + +test('Sensitivity option change should be correctly handled during runtime change', async (t) => { + const dataGrid = new DataGrid(DATA_GRID_SELECTOR); + const checkBoxCell = dataGrid.getDataCell(0, 0); + const firstRow = dataGrid.getDataRow(0); + const secondRow = dataGrid.getDataRow(1); + + await t.click(checkBoxCell.element); + + await t + .expect(firstRow.isSelected).ok() + .expect(secondRow.isSelected).ok(); + + await dataGrid.apiChangeSensitivity('case'); + + await t + .expect(firstRow.isSelected).notOk() + .expect(secondRow.isSelected).notOk(); + + await t.click(checkBoxCell.element); + + await t + .expect(firstRow.isSelected).ok() + .expect(secondRow.isSelected).notOk(); +}).before(() => createWidget('dxDataGrid', { + dataSource: data, + keyExpr: 'ID', + columns: ['ID', 'Name'], + showBorders: true, + selection: { + mode: 'multiple', + deferred: true, + sensitivity: 'base', + }, +})); + +// --- diff --git a/e2e/testcafe-devextreme/tests/pager/accessibility.ts b/e2e/testcafe-devextreme/tests/pager/accessibility.ts new file mode 100644 index 000000000000..13481610b537 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/pager/accessibility.ts @@ -0,0 +1,67 @@ +import { createScreenshotsComparer } from 'devextreme-screenshot-comparer'; +import Pager from 'devextreme-testcafe-models/pager'; +import url from '../../helpers/getPageUrl'; +import { testAccessibility, Configuration } from '../../helpers/accessibility/test'; +import { Options } from '../../helpers/generateOptionMatrix'; + +fixture.disablePageReloads`Pager` + .page(url(__dirname, '../container.html')); + +const options: Options = { + // disabled: [true, false], //not supported + displayMode: ['full', 'compact'], + infoText: [undefined, 'Total {2} items. Page {0} of {1}'], + pageCount: [10, 100], + pageSizes: [[1, 2, 3], [3, 6, 9]], + showInfo: [true, false], + showNavigationButtons: [true, false], + showPageSizeSelector: [true, false], + visible: [true], +}; + +const defaultCreated = async (): Promise => {}; +const created = async (t: TestController, optionConfiguration): Promise => { + const { + visible, + displayMode, + infoText, + pageCount, + pageSizes, + showInfo, + showNavigationButtons, + showPageSizeSelector, + } = optionConfiguration; + + if (!visible) { + return; + } + + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const pager = new Pager('#container'); + await t + .expect(await takeScreenshot(`pager-dm_${displayMode}-` + + `${infoText ? 'has' : 'has_no'}_it-` + + `pc_${pageCount}-` + + `ps_${pageSizes[0]}_${pageSizes[1]}_${pageSizes[2]}-` + + `si_${showInfo.toString()}-` + + `snb_${showNavigationButtons.toString()}-` + + `spss_${showPageSizeSelector.toString()}` + + '.png', pager.element)) + .ok() + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}; + +const a11yCheckConfig = { + // NOTE: color-contrast issues + rules: { 'color-contrast': { enabled: false } }, +}; + +const configuration: Configuration = { + component: 'dxPager', + a11yCheckConfig, + options, + created: defaultCreated || created, // Waiting pager specification +}; + +testAccessibility(configuration); diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_appts_view-month.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_appts_view-month.png index 944e5f470aa2..10e829f69c34 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_appts_view-month.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_appts_view-month.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_appts_view-week.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_appts_view-week.png index 944e5f470aa2..10e829f69c34 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_appts_view-week.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_appts_view-week.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_long-appts-without-all-day-panel_view-week.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_long-appts-without-all-day-panel_view-week.png index 8eaef83d3705..742958ed5343 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_long-appts-without-all-day-panel_view-week.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/etalons/adaptive_long-appts-without-all-day-panel_view-week.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=1.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=1.png index c15bee3dbe49..da465e9ee82f 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=1.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=1.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=3.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=3.png index b56aacb07996..a2e7e6674b4f 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=3.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=3.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=auto.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=auto.png index fdbf7deb046f..e9dafbd944c6 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=auto.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/all-day-appointment-maxAppointmentsPerCell=auto.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=10.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=10.png index 165ce8dc29ae..d29413c7d1e1 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=10.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=10.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=3.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=3.png index 41ddcc63408f..63ce06d4443b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=3.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=3.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=auto.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=auto.png index bf0ee4f6bd26..4e315974534b 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=auto.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/day-appointment-maxAppointmentsPerCell=auto.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=1.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=1.png index 8330b5972285..f2061ce2671f 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=1.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=1.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=3.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=3.png index 06503d0688c9..b6b6e7794968 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=3.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=3.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=auto.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=auto.png index 06503d0688c9..b6b6e7794968 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=auto.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/month-appointment-maxAppointmentsPerCell=auto.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=1.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=1.png index 4c9cac7d0ac8..ad0281ec53f6 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=1.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=1.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=10.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=10.png index 0188d82b7959..664250d56d67 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=10.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=10.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=3.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=3.png index a9ada399fda8..e7f292bf92a4 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=3.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=3.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=auto.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=auto.png index db1a44a5d47a..3438c6e5f04a 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=auto.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/timeline-appointment-maxAppointmentsPerCell=auto.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=10.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=10.png index 0650fdf4ce14..608b969b3467 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=10.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=10.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=3.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=3.png index 7625a230036f..eb710bdc9350 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=3.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=3.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=auto.png b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=auto.png index f0f69e5d4896..aae6c643d088 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=auto.png and b/e2e/testcafe-devextreme/tests/scheduler/appointments/maxAppointmentsPerCell/etalons/week-appointment-maxAppointmentsPerCell=auto.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/dragAndDrop/outlookDragging/etalons/drag-n-drop-'Appointment 3'-from-tooltip-in-month.png b/e2e/testcafe-devextreme/tests/scheduler/dragAndDrop/outlookDragging/etalons/drag-n-drop-'Appointment 3'-from-tooltip-in-month.png index 1d88fb03ef6c..273a60dc3f38 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/dragAndDrop/outlookDragging/etalons/drag-n-drop-'Appointment 3'-from-tooltip-in-month.png and b/e2e/testcafe-devextreme/tests/scheduler/dragAndDrop/outlookDragging/etalons/drag-n-drop-'Appointment 3'-from-tooltip-in-month.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/dragAndDrop/outlookDragging/etalons/drag-n-drop-'Appointment 3'-from-tooltip-in-week.png b/e2e/testcafe-devextreme/tests/scheduler/dragAndDrop/outlookDragging/etalons/drag-n-drop-'Appointment 3'-from-tooltip-in-week.png index 945f44635e93..0df04bd7ecfe 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/dragAndDrop/outlookDragging/etalons/drag-n-drop-'Appointment 3'-from-tooltip-in-week.png and b/e2e/testcafe-devextreme/tests/scheduler/dragAndDrop/outlookDragging/etalons/drag-n-drop-'Appointment 3'-from-tooltip-in-week.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/fluent-view=week-crossScrolling=false-horizontal-rtl.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/fluent-view=week-crossScrolling=false-horizontal-rtl.png index 5a1d4e87b539..53d4c8e181b0 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/fluent-view=week-crossScrolling=false-horizontal-rtl.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/fluent-view=week-crossScrolling=false-horizontal-rtl.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/fluent-view=week-crossScrolling=false-horizontal.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/fluent-view=week-crossScrolling=false-horizontal.png index be6000c968aa..75f143421fe0 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/fluent-view=week-crossScrolling=false-horizontal.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/fluent-view=week-crossScrolling=false-horizontal.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=false-rtl.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=false-rtl.png index 60d4fa55c337..5aa2386d4e79 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=false-rtl.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=false-rtl.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=false.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=false.png index e17ee4cd5d2a..313d8f99224d 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=false.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=false.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=true-rtl.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=true-rtl.png index 3be3f62ec526..7a4e7dd8178e 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=true-rtl.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=true-rtl.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=true.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=true.png index 49d5e8f219b8..895d94c7858c 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=true.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=month-crossScrolling=true.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=week-crossScrolling=false-horizontal-rtl.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=week-crossScrolling=false-horizontal-rtl.png index 3801df32d752..a4ebab88ec81 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=week-crossScrolling=false-horizontal-rtl.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=week-crossScrolling=false-horizontal-rtl.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=week-crossScrolling=false-horizontal.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=week-crossScrolling=false-horizontal.png index cbdf92aaa04e..e3b0b260e4d9 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=week-crossScrolling=false-horizontal.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/generic-view=week-crossScrolling=false-horizontal.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/material-view=week-crossScrolling=false-horizontal-rtl.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/material-view=week-crossScrolling=false-horizontal-rtl.png index d4dc47e1b2b8..1fbbc6d3eb91 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/material-view=week-crossScrolling=false-horizontal-rtl.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/material-view=week-crossScrolling=false-horizontal-rtl.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/material-view=week-crossScrolling=false-horizontal.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/material-view=week-crossScrolling=false-horizontal.png index 4bc4aa0b9bde..cb8494293094 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/material-view=week-crossScrolling=false-horizontal.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/etalons/material-view=week-crossScrolling=false-horizontal.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/resize/etalons/browser-resize-currentView=month-after-resize.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/resize/etalons/browser-resize-currentView=month-after-resize.png index 8111178354fd..4c283a04b3f2 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/resize/etalons/browser-resize-currentView=month-after-resize.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/resize/etalons/browser-resize-currentView=month-after-resize.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/resize/etalons/browser-resize-currentView=month-before-resize.png b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/resize/etalons/browser-resize-currentView=month-before-resize.png index 54fb3f071a32..120c717dafac 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/resize/etalons/browser-resize-currentView=month-before-resize.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/adaptive/resize/etalons/browser-resize-currentView=month-before-resize.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/appointment-collector-adaptability-timelineMonth.png b/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/appointment-collector-adaptability-timelineMonth.png index a4ddb7a9b2d5..0bc9d237df3d 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/appointment-collector-adaptability-timelineMonth.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/appointment-collector-adaptability-timelineMonth.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/filtering-visible=true-maxAppointmentsPerCell=0.png b/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/filtering-visible=true-maxAppointmentsPerCell=0.png index f743ac2891ab..a1dba428c392 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/filtering-visible=true-maxAppointmentsPerCell=0.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/filtering-visible=true-maxAppointmentsPerCell=0.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/filtering-visible=undefined-maxAppointmentsPerCell=0.png b/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/filtering-visible=undefined-maxAppointmentsPerCell=0.png index f743ac2891ab..a1dba428c392 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/filtering-visible=undefined-maxAppointmentsPerCell=0.png and b/e2e/testcafe-devextreme/tests/scheduler/layout/appointments/etalons/filtering-visible=undefined-maxAppointmentsPerCell=0.png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/resources/base/etalons/generic-resource(view=month-resource=false).png b/e2e/testcafe-devextreme/tests/scheduler/layout/resources/base/etalons/generic-resource(view=month-resource=false).png index 775798c574a6..70bd8ea859a0 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/resources/base/etalons/generic-resource(view=month-resource=false).png and b/e2e/testcafe-devextreme/tests/scheduler/layout/resources/base/etalons/generic-resource(view=month-resource=false).png differ diff --git a/e2e/testcafe-devextreme/tests/scheduler/layout/resources/base/etalons/generic-resource(view=month-resource=true).png b/e2e/testcafe-devextreme/tests/scheduler/layout/resources/base/etalons/generic-resource(view=month-resource=true).png index 7929de18ad5a..cfad0856c98f 100644 Binary files a/e2e/testcafe-devextreme/tests/scheduler/layout/resources/base/etalons/generic-resource(view=month-resource=true).png and b/e2e/testcafe-devextreme/tests/scheduler/layout/resources/base/etalons/generic-resource(view=month-resource=true).png differ diff --git a/packages/devextreme-angular/src/http/index.ts b/packages/devextreme-angular/src/http/index.ts index b78f528bc2cc..90b6b7088293 100644 --- a/packages/devextreme-angular/src/http/index.ts +++ b/packages/devextreme-angular/src/http/index.ts @@ -1,16 +1,23 @@ -import { NgModule } from '@angular/core'; +import { NgModule, Injector, createNgModuleRef } from '@angular/core'; import { HttpClient, HttpClientModule } from '@angular/common/http'; import devextremeAjax from 'devextreme/core/utils/ajax'; -// eslint-disable-next-line import/named import { sendRequestFactory } from './ajax'; @NgModule({ exports: [], - imports: [HttpClientModule], + imports: [], providers: [], }) export class DxHttpModule { - constructor(httpClient: HttpClient) { + constructor(injector: Injector) { + let httpClient: HttpClient = injector.get(HttpClient, null); + + if (!httpClient) { + const moduleRef = createNgModuleRef(HttpClientModule, injector); + + httpClient = moduleRef.injector.get(HttpClient); + } + devextremeAjax.inject({ sendRequest: sendRequestFactory(httpClient) }); } } diff --git a/packages/devextreme-angular/src/ui/chat/index.ts b/packages/devextreme-angular/src/ui/chat/index.ts index e5d571d69498..76c5db468900 100644 --- a/packages/devextreme-angular/src/ui/chat/index.ts +++ b/packages/devextreme-angular/src/ui/chat/index.ts @@ -62,6 +62,32 @@ import { DxiItemComponent } from 'devextreme-angular/ui/nested'; export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges, DoCheck { instance: DxChat = null; + /** + * [descr:WidgetOptions.accessKey] + + */ + @Input() + get accessKey(): string | undefined { + return this._getOption('accessKey'); + } + set accessKey(value: string | undefined) { + this._setOption('accessKey', value); + } + + + /** + * [descr:WidgetOptions.activeStateEnabled] + + */ + @Input() + get activeStateEnabled(): boolean { + return this._getOption('activeStateEnabled'); + } + set activeStateEnabled(value: boolean) { + this._setOption('activeStateEnabled', value); + } + + /** * [descr:WidgetOptions.disabled] @@ -88,6 +114,19 @@ export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges } + /** + * [descr:WidgetOptions.focusStateEnabled] + + */ + @Input() + get focusStateEnabled(): boolean { + return this._getOption('focusStateEnabled'); + } + set focusStateEnabled(value: boolean) { + this._setOption('focusStateEnabled', value); + } + + /** * [descr:DOMComponentOptions.height] @@ -101,6 +140,19 @@ export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges } + /** + * [descr:WidgetOptions.hint] + + */ + @Input() + get hint(): string | undefined { + return this._getOption('hint'); + } + set hint(value: string | undefined) { + this._setOption('hint', value); + } + + /** * [descr:WidgetOptions.hoverStateEnabled] @@ -223,6 +275,20 @@ export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges */ @Output() onOptionChanged: EventEmitter; + /** + + * This member supports the internal infrastructure and is not intended to be used directly from your code. + + */ + @Output() accessKeyChange: EventEmitter; + + /** + + * This member supports the internal infrastructure and is not intended to be used directly from your code. + + */ + @Output() activeStateEnabledChange: EventEmitter; + /** * This member supports the internal infrastructure and is not intended to be used directly from your code. @@ -237,6 +303,13 @@ export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges */ @Output() elementAttrChange: EventEmitter; + /** + + * This member supports the internal infrastructure and is not intended to be used directly from your code. + + */ + @Output() focusStateEnabledChange: EventEmitter; + /** * This member supports the internal infrastructure and is not intended to be used directly from your code. @@ -244,6 +317,13 @@ export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges */ @Output() heightChange: EventEmitter; + /** + + * This member supports the internal infrastructure and is not intended to be used directly from your code. + + */ + @Output() hintChange: EventEmitter; + /** * This member supports the internal infrastructure and is not intended to be used directly from your code. @@ -321,9 +401,13 @@ export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges { subscribe: 'initialized', emit: 'onInitialized' }, { subscribe: 'messageSend', emit: 'onMessageSend' }, { subscribe: 'optionChanged', emit: 'onOptionChanged' }, + { emit: 'accessKeyChange' }, + { emit: 'activeStateEnabledChange' }, { emit: 'disabledChange' }, { emit: 'elementAttrChange' }, + { emit: 'focusStateEnabledChange' }, { emit: 'heightChange' }, + { emit: 'hintChange' }, { emit: 'hoverStateEnabledChange' }, { emit: 'itemsChange' }, { emit: 'rtlEnabledChange' }, diff --git a/packages/devextreme-angular/src/ui/data-grid/index.ts b/packages/devextreme-angular/src/ui/data-grid/index.ts index 271be1ca9ba5..5153e438ceb3 100644 --- a/packages/devextreme-angular/src/ui/data-grid/index.ts +++ b/packages/devextreme-angular/src/ui/data-grid/index.ts @@ -30,7 +30,7 @@ import { UserDefinedElement } from 'devextreme/core/element'; import { Store } from 'devextreme/data'; import DataSource, { Options as DataSourceOptions } from 'devextreme/data/data_source'; import { Format } from 'devextreme/localization'; -import { AdaptiveDetailRowPreparingEvent, CellClickEvent, CellDblClickEvent, CellHoverChangedEvent, CellPreparedEvent, ContentReadyEvent, ContextMenuPreparingEvent, DataErrorOccurredEvent, DataGridExportFormat, DataGridScrollMode, DisposingEvent, dxDataGridColumn, dxDataGridToolbar, EditCanceledEvent, EditCancelingEvent, EditingStartEvent, EditorPreparedEvent, EditorPreparingEvent, ExportingEvent, FocusedCellChangedEvent, FocusedCellChangingEvent, FocusedRowChangedEvent, FocusedRowChangingEvent, InitializedEvent, InitNewRowEvent, KeyDownEvent, OptionChangedEvent, RowClickEvent, RowCollapsedEvent, RowCollapsingEvent, RowDblClickEvent, RowExpandedEvent, RowExpandingEvent, RowInsertedEvent, RowInsertingEvent, RowPreparedEvent, RowRemovedEvent, RowRemovingEvent, RowUpdatedEvent, RowUpdatingEvent, RowValidatingEvent, SavedEvent, SavingEvent, SelectionChangedEvent, ToolbarPreparingEvent } from 'devextreme/ui/data_grid'; +import { AdaptiveDetailRowPreparingEvent, CellClickEvent, CellDblClickEvent, CellHoverChangedEvent, CellPreparedEvent, ContentReadyEvent, ContextMenuPreparingEvent, DataErrorOccurredEvent, DataGridExportFormat, DataGridScrollMode, DisposingEvent, dxDataGridColumn, dxDataGridToolbar, EditCanceledEvent, EditCancelingEvent, EditingStartEvent, EditorPreparedEvent, EditorPreparingEvent, ExportingEvent, FocusedCellChangedEvent, FocusedCellChangingEvent, FocusedRowChangedEvent, FocusedRowChangingEvent, InitializedEvent, InitNewRowEvent, KeyDownEvent, OptionChangedEvent, RowClickEvent, RowCollapsedEvent, RowCollapsingEvent, RowDblClickEvent, RowExpandedEvent, RowExpandingEvent, RowInsertedEvent, RowInsertingEvent, RowPreparedEvent, RowRemovedEvent, RowRemovingEvent, RowUpdatedEvent, RowUpdatingEvent, RowValidatingEvent, SavedEvent, SavingEvent, SelectionChangedEvent, SelectionSensitivity, ToolbarPreparingEvent } from 'devextreme/ui/data_grid'; import { Properties as dxFilterBuilderOptions } from 'devextreme/ui/filter_builder'; import { Properties as dxFormOptions } from 'devextreme/ui/form'; import { Properties as dxPopupOptions } from 'devextreme/ui/popup'; @@ -896,10 +896,10 @@ export class DxDataGridComponent extends DxComponent */ @Input() - get selection(): { allowSelectAll?: boolean, deferred?: boolean, mode?: SingleMultipleOrNone, selectAllMode?: SelectAllMode, showCheckBoxesMode?: SelectionColumnDisplayMode } { + get selection(): { allowSelectAll?: boolean, deferred?: boolean, mode?: SingleMultipleOrNone, selectAllMode?: SelectAllMode, sensitivity?: SelectionSensitivity, showCheckBoxesMode?: SelectionColumnDisplayMode } { return this._getOption('selection'); } - set selection(value: { allowSelectAll?: boolean, deferred?: boolean, mode?: SingleMultipleOrNone, selectAllMode?: SelectAllMode, showCheckBoxesMode?: SelectionColumnDisplayMode }) { + set selection(value: { allowSelectAll?: boolean, deferred?: boolean, mode?: SingleMultipleOrNone, selectAllMode?: SelectAllMode, sensitivity?: SelectionSensitivity, showCheckBoxesMode?: SelectionColumnDisplayMode }) { this._setOption('selection', value); } @@ -1850,7 +1850,7 @@ export class DxDataGridComponent extends DxComponent * This member supports the internal infrastructure and is not intended to be used directly from your code. */ - @Output() selectionChange: EventEmitter<{ allowSelectAll?: boolean, deferred?: boolean, mode?: SingleMultipleOrNone, selectAllMode?: SelectAllMode, showCheckBoxesMode?: SelectionColumnDisplayMode }>; + @Output() selectionChange: EventEmitter<{ allowSelectAll?: boolean, deferred?: boolean, mode?: SingleMultipleOrNone, selectAllMode?: SelectAllMode, sensitivity?: SelectionSensitivity, showCheckBoxesMode?: SelectionColumnDisplayMode }>; /** diff --git a/packages/devextreme-angular/src/ui/nested/base/column-chooser-selection-config.ts b/packages/devextreme-angular/src/ui/nested/base/column-chooser-selection-config.ts index 48e7b213e74f..64a5ef9ee207 100644 --- a/packages/devextreme-angular/src/ui/nested/base/column-chooser-selection-config.ts +++ b/packages/devextreme-angular/src/ui/nested/base/column-chooser-selection-config.ts @@ -7,6 +7,7 @@ import { import { SelectAllMode, SingleMultipleOrNone } from 'devextreme/common'; import { SelectionColumnDisplayMode } from 'devextreme/common/grids'; +import { SelectionSensitivity } from 'devextreme/ui/data_grid'; @Component({ template: '' @@ -54,6 +55,13 @@ export abstract class DxoColumnChooserSelectionConfig extends NestedOption { this._setOption('selectAllMode', value); } + get sensitivity(): SelectionSensitivity { + return this._getOption('sensitivity'); + } + set sensitivity(value: SelectionSensitivity) { + this._setOption('sensitivity', value); + } + get showCheckBoxesMode(): SelectionColumnDisplayMode { return this._getOption('showCheckBoxesMode'); } diff --git a/packages/devextreme-angular/src/ui/nested/selection.ts b/packages/devextreme-angular/src/ui/nested/selection.ts index deaf32c9b24e..a333f42d06aa 100644 --- a/packages/devextreme-angular/src/ui/nested/selection.ts +++ b/packages/devextreme-angular/src/ui/nested/selection.ts @@ -33,6 +33,7 @@ import { DxoColumnChooserSelectionConfig } from './base/column-chooser-selection 'deferred', 'mode', 'selectAllMode', + 'sensitivity', 'showCheckBoxesMode' ] }) diff --git a/packages/devextreme-angular/tests/src/http/ajax-bootstrap-interceptors.spec.ts b/packages/devextreme-angular/tests/src/http/ajax-bootstrap-interceptors.spec.ts new file mode 100644 index 000000000000..4d042e5cfc74 --- /dev/null +++ b/packages/devextreme-angular/tests/src/http/ajax-bootstrap-interceptors.spec.ts @@ -0,0 +1,106 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { + provideHttpClient, + withInterceptors, + HttpInterceptorFn, + HttpClient, +} from '@angular/common/http'; +import { ApplicationRef, Component } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { DxHttpModule } from 'devextreme-angular/http'; +import DataSource from 'devextreme/data/data_source'; +import ODataStore from 'devextreme/data/odata/store'; +import { throwError } from 'rxjs'; + +const TEST_URL = ''; +const interceptors: Record void> = {}; + +interceptors.interceptorFn = () => {}; + +const testInterceptorFn: HttpInterceptorFn = () => { + interceptors.interceptorFn(); + return throwError(() => ({ + status: 403, + statusText: 'Request intercepted. Access Denied', + })); +}; + +@Component({ + standalone: true, + selector: 'test-app', + imports: [DxHttpModule], + template: '---', +}) +class TestAppComponent { + constructor(private readonly httpClient: HttpClient) {} + + fetchData() { + return this.httpClient.get(TEST_URL).toPromise(); + } + + loadDataSource() { + const dataSource = new DataSource({ + store: new ODataStore({ + version: 2, + url: TEST_URL, + }), + }); + + return dataSource.load() as Promise; + } +} + +describe('Using DxHttpModule in application with interceptors provided in bootstrapApplication() ', () => { + let httpTestingControllerMock: HttpTestingController; + let component: TestAppComponent; + + beforeEach(async () => { + const testApp = document.createElement('test-app'); + + document.body.appendChild(testApp); + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + const appRef = await bootstrapApplication(TestAppComponent, { + providers: [ + provideHttpClient(withInterceptors([testInterceptorFn])), + { provide: HttpClientTestingModule }, + ], + }); + + const applicationRef = appRef.injector.get(ApplicationRef); + + component = applicationRef.components[0].instance as TestAppComponent; + + httpTestingControllerMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTestingControllerMock?.verify(); + }); + + it('should call interceptors while calling httpClient directly ', (done) => { + const interceptorFnSpy = spyOn(interceptors, 'interceptorFn'); + + component + .fetchData() + .catch(() => { + expect(interceptorFnSpy).toHaveBeenCalledTimes(1); + done(); + }).finally(() => {}); + }); + + it('dataSource load() should be intercepted', (done) => { + const interceptorFnSpy = spyOn(interceptors, 'interceptorFn'); + + // eslint-disable-next-line no-void + void component.loadDataSource() + .catch(() => { + expect(interceptorFnSpy).toHaveBeenCalledTimes(1); + done(); + }); + }); +}); diff --git a/packages/devextreme-react/src/data-grid.ts b/packages/devextreme-react/src/data-grid.ts index c0bbb51c7dfc..97c74673f41c 100644 --- a/packages/devextreme-react/src/data-grid.ts +++ b/packages/devextreme-react/src/data-grid.ts @@ -873,6 +873,7 @@ type IDataGridSelectionProps = React.PropsWithChildren<{ deferred?: boolean; mode?: "single" | "multiple" | "none"; selectAllMode?: "allPages" | "page"; + sensitivity?: "base" | "accent" | "case" | "variant"; showCheckBoxesMode?: "always" | "none" | "onClick" | "onLongTap"; }> const _componentDataGridSelection = memo( @@ -2351,6 +2352,7 @@ type ISelectionProps = React.PropsWithChildren<{ deferred?: boolean; mode?: "single" | "multiple" | "none"; selectAllMode?: "allPages" | "page"; + sensitivity?: "base" | "accent" | "case" | "variant"; showCheckBoxesMode?: "always" | "none" | "onClick" | "onLongTap"; recursive?: boolean; selectByClick?: boolean; diff --git a/packages/devextreme-scss/scss/widgets/base/chat/_index.scss b/packages/devextreme-scss/scss/widgets/base/chat/_index.scss index f253605a570d..9b254cb2e07a 100644 --- a/packages/devextreme-scss/scss/widgets/base/chat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/chat/_index.scss @@ -35,13 +35,6 @@ $chat-text-area-height: 40px; overflow: hidden; } -.dx-chat-message-list-content { - display: flex; - flex-direction: column; - padding: 0; - margin: 0; -} - .dx-chat-message-group { display: grid; align-items: start; @@ -68,12 +61,12 @@ $chat-text-area-height: 40px; } .dx-chat-message-time, -.dx-chat-message-name { +.dx-chat-message-author-name { font-size: $chat-information-font-size; color: $chat-information-color; } -.dx-chat-message-name { +.dx-chat-message-author-name { margin-right: 8px; } diff --git a/packages/devextreme-scss/scss/widgets/base/scheduler/_common.scss b/packages/devextreme-scss/scss/widgets/base/scheduler/_common.scss index 9738efc5604e..968203275f23 100644 --- a/packages/devextreme-scss/scss/widgets/base/scheduler/_common.scss +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/_common.scss @@ -50,7 +50,7 @@ $scheduler-popup-scrollable-content-padding: 20px; position: relative; height: 100%; content: ''; - vertical-align: middle; + vertical-align: text-top; font-size: 0; } diff --git a/packages/devextreme-scss/scss/widgets/base/scheduler/_index.scss b/packages/devextreme-scss/scss/widgets/base/scheduler/_index.scss index 5b097140e00c..4571516afcc1 100644 --- a/packages/devextreme-scss/scss/widgets/base/scheduler/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/_index.scss @@ -501,19 +501,6 @@ $scheduler-appointment-form-label-padding: 20px; } .dx-scheduler-appointment-collector { - background-color: $scheduler-appointment-base-color; - color: $scheduler-appointment-text-color; - - &.dx-button, - &.dx-button.dx-state-hover, - &.dx-button.dx-state-active, - &.dx-button.dx-state-focused { - background-color: $scheduler-appointment-base-color; - color: $scheduler-appointment-text-color; - border: none; - box-shadow: none; - } - &.dx-button.dx-state-hover { &::before { pointer-events: none; @@ -523,14 +510,6 @@ $scheduler-appointment-form-label-padding: 20px; left: 0; right: 0; bottom: 0; - background-color: $scheduler-appointment-start-color; - opacity: 0.98; - } - - .dx-scheduler-appointment-collector-content, - .dx-button-content { - color: $scheduler-dd-appointment-hover-text-color; - opacity: 0.99; } } } diff --git a/packages/devextreme-scss/scss/widgets/fluent/gantt/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/gantt/_index.scss index 7697b08022d6..d9338e475509 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/gantt/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/gantt/_index.scss @@ -34,7 +34,8 @@ $gantt-successor-background-color: white; background-color: $base-bg; } - .dx-header-row { + .dx-header-row, + .dx-treelist-filter-row { height: $gantt-header-item-height; } diff --git a/packages/devextreme-scss/scss/widgets/generic/gantt/_index.scss b/packages/devextreme-scss/scss/widgets/generic/gantt/_index.scss index 7d08bb8d0df2..17abda3102b0 100644 --- a/packages/devextreme-scss/scss/widgets/generic/gantt/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/gantt/_index.scss @@ -79,7 +79,8 @@ $gantt-successor-background-color: white; background-color: $base-bg; } - .dx-header-row { + .dx-header-row, + .dx-treelist-filter-row { height: $gantt-header-item-height; } diff --git a/packages/devextreme-scss/scss/widgets/material/gantt/_index.scss b/packages/devextreme-scss/scss/widgets/material/gantt/_index.scss index 744436a14838..6433a70d293c 100644 --- a/packages/devextreme-scss/scss/widgets/material/gantt/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/gantt/_index.scss @@ -34,7 +34,8 @@ $gantt-successor-background-color: white; background-color: $base-bg; } - .dx-header-row { + .dx-header-row, + .dx-treelist-filter-row { height: $gantt-header-item-height; } diff --git a/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss b/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss index e036b14c09b7..fcc5b731e2ee 100644 --- a/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/scheduler/_index.scss @@ -357,6 +357,7 @@ $material-scheduler-agenda-time-panel-cell-padding: 8px; position: absolute; &.dx-button { + box-shadow: none; border-radius: $material-scheduler-compact-appointment-button-border-radius; height: $material-scheduler-dropdown-button-height; min-width: auto; diff --git a/packages/devextreme-vue/src/chat.ts b/packages/devextreme-vue/src/chat.ts index dae13182da70..54a67dc1680e 100644 --- a/packages/devextreme-vue/src/chat.ts +++ b/packages/devextreme-vue/src/chat.ts @@ -3,9 +3,13 @@ import { createComponent } from "./core/index"; import { createConfigurationComponent } from "./core/index"; type AccessibleOptions = Pick { public state: any = {}; + getConfig(): ConfigContextValue { + return { + rtlEnabled: this.props.rtlEnabled, + }; + } + + getChildContext(): any { + return { + ...this.context, + ...{ + [ConfigContext.id]: this.getConfig() || ConfigContext.defaultValue, + }, + }; + } + render(): JSX.Element { return ( this.props.children diff --git a/packages/devextreme/js/__internal/core/widget/component.ts b/packages/devextreme/js/__internal/core/widget/component.ts new file mode 100644 index 000000000000..b9eaa0f2a6ab --- /dev/null +++ b/packages/devextreme/js/__internal/core/widget/component.ts @@ -0,0 +1,434 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable consistent-return */ +/* eslint-disable no-param-reassign */ +/* eslint-disable @typescript-eslint/init-declarations */ +/* eslint-disable no-plusplus */ +/* eslint-disable @typescript-eslint/prefer-for-of */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable default-case */ +/* eslint-disable @typescript-eslint/prefer-optional-chain */ +/* eslint-disable max-len */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/restrict-plus-operands */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import Action from '@js/core/action'; +import Class from '@js/core/class'; +import Config from '@js/core/config'; +import errors from '@js/core/errors'; +import { EventsStrategy } from '@js/core/events_strategy'; +import { Options } from '@js/core/options/index'; +import { convertRulesToOptions } from '@js/core/options/utils'; +import { PostponedOperations } from '@js/core/postponed_operations'; +import Callbacks from '@js/core/utils/callbacks'; +import { noop } from '@js/core/utils/common'; +import { getPathParts } from '@js/core/utils/data'; +import { extend } from '@js/core/utils/extend'; +import { name as publicComponentName } from '@js/core/utils/public_component'; +import { isDefined, isFunction, isPlainObject } from '@js/core/utils/type'; + +const getEventName = (actionName) => actionName.charAt(2).toLowerCase() + actionName.substr(3); + +const isInnerOption = (optionName) => optionName.indexOf('_', 0) === 0; + +export class Component extends Class.inherit({}) { + _deprecatedOptions: any; + + _options: any; + + _optionsByReference: any; + + NAME: any; + + _eventsStrategy: any; + + _optionChangedCallbacks: any; + + _updateLockCount: any; + + _disposingCallbacks: any; + + postponedOperations: any; + + _initialized: any; + + _optionChangedAction: any; + + _disposingAction: any; + + _disposed: any; + + _initializing: any; + + _cancelOptionChange: any; + + _setDeprecatedOptions() { + this._deprecatedOptions = {}; + } + + _getDeprecatedOptions() { + return this._deprecatedOptions; + } + + _getDefaultOptions() { + return { + onInitialized: null, + onOptionChanged: null, + onDisposing: null, + + defaultOptionsRules: null, + }; + } + + _defaultOptionsRules(): any[] { + return []; + } + + _setOptionsByDevice(rules) { + this._options.applyRules(rules); + } + + _convertRulesToOptions(rules) { + return convertRulesToOptions(rules); + } + + _isInitialOptionValue(name) { + return this._options.isInitial(name); + } + + _setOptionsByReference() { + this._optionsByReference = {}; + } + + _getOptionsByReference() { + return this._optionsByReference; + } + + ctor(options: any = {}) { + const { _optionChangedCallbacks, _disposingCallbacks } = options; + + this.NAME = publicComponentName(this.constructor); + + this._eventsStrategy = EventsStrategy.create(this, options.eventsStrategy); + + this._updateLockCount = 0; + + this._optionChangedCallbacks = _optionChangedCallbacks || Callbacks(); + this._disposingCallbacks = _disposingCallbacks || Callbacks(); + this.postponedOperations = new PostponedOperations(); + this._createOptions(options); + } + + _createOptions(options) { + this.beginUpdate(); + + try { + this._setOptionsByReference(); + this._setDeprecatedOptions(); + this._options = new Options( + this._getDefaultOptions(), + this._getDefaultOptions(), + this._getOptionsByReference(), + this._getDeprecatedOptions(), + ); + + this._options.onChanging( + (name, previousValue, value) => this._initialized && this._optionChanging(name, previousValue, value), + ); + this._options.onDeprecated( + (option, info) => this._logDeprecatedOptionWarning(option, info), + ); + this._options.onChanged( + (name, value, previousValue) => this._notifyOptionChanged(name, value, previousValue), + ); + this._options.onStartChange(() => this.beginUpdate()); + this._options.onEndChange(() => this.endUpdate()); + this._options.addRules(this._defaultOptionsRules()); + + if (options && options.onInitializing) { + options.onInitializing.apply(this, [options]); + } + + this._setOptionsByDevice(options.defaultOptionsRules); + this._initOptions(options); + } finally { + this.endUpdate(); + } + } + + _initOptions(options) { + this.option(options); + } + + _init() { + this._createOptionChangedAction(); + + this.on('disposing', (args) => { + this._disposingCallbacks.fireWith(this, [args]); + }); + } + + _logDeprecatedOptionWarning(option, info) { + const message = info.message || `Use the '${info.alias}' option instead`; + errors.log('W0001', this.NAME, option, info.since, message); + } + + _logDeprecatedComponentWarning(since, alias) { + errors.log('W0000', this.NAME, since, `Use the '${alias}' widget instead`); + } + + _createOptionChangedAction() { + this._optionChangedAction = this._createActionByOption('onOptionChanged', { excludeValidators: ['disabled', 'readOnly'] }); + } + + _createDisposingAction() { + this._disposingAction = this._createActionByOption('onDisposing', { excludeValidators: ['disabled', 'readOnly'] }); + } + + _optionChanged(args) { + switch (args.name) { + case 'onDisposing': + case 'onInitialized': + break; + case 'onOptionChanged': + this._createOptionChangedAction(); + break; + case 'defaultOptionsRules': + break; + } + } + + _dispose() { + this._optionChangedCallbacks.empty(); + this._createDisposingAction(); + this._disposingAction(); + this._eventsStrategy.dispose(); + this._options.dispose(); + this._disposed = true; + } + + _lockUpdate() { + this._updateLockCount++; + } + + _unlockUpdate() { + this._updateLockCount = Math.max(this._updateLockCount - 1, 0); + } + + // TODO: remake as getter after ES6 refactor + _isUpdateAllowed() { + return this._updateLockCount === 0; + } + + // TODO: remake as getter after ES6 refactor + _isInitializingRequired() { + return !this._initializing && !this._initialized; + } + + isInitialized() { + return this._initialized; + } + + _commitUpdate() { + this.postponedOperations.callPostponedOperations(); + this._isInitializingRequired() && this._initializeComponent(); + } + + _initializeComponent() { + this._initializing = true; + + try { + this._init(); + } finally { + this._initializing = false; + this._lockUpdate(); + this._createActionByOption('onInitialized', { excludeValidators: ['disabled', 'readOnly'] })(); + this._unlockUpdate(); + this._initialized = true; + } + } + + instance() { + return this; + } + + beginUpdate() { + this._lockUpdate(); + } + + endUpdate() { + this._unlockUpdate(); + this._isUpdateAllowed() && this._commitUpdate(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _optionChanging(...args: any[]) { + + } + + _notifyOptionChanged(option, value, previousValue) { + if (this._initialized) { + const optionNames = [option].concat(this._options.getAliasesByName(option)); + for (let i = 0; i < optionNames.length; i++) { + const name = optionNames[i]; + const args = { + name: getPathParts(name)[0], + fullName: name, + value, + previousValue, + }; + + if (!isInnerOption(name)) { + this._optionChangedCallbacks.fireWith(this, [extend(this._defaultActionArgs(), args)]); + this._optionChangedAction(extend({}, args)); + } + + if (!this._disposed && this._cancelOptionChange !== name) { + this._optionChanged(args); + } + } + } + } + + initialOption(name) { + return this._options.initial(name); + } + + _defaultActionConfig() { + return { + context: this, + component: this, + }; + } + + _defaultActionArgs() { + return { + component: this, + }; + } + + _createAction(actionSource, config) { + let action; + + return (e) => { + if (!isDefined(e)) { + e = {}; + } + + if (!isPlainObject(e)) { + e = { actionValue: e }; + } + action = action || new Action(actionSource, extend({}, config, this._defaultActionConfig())); + + return action.execute.call(action, extend(e, this._defaultActionArgs())); + }; + } + + _createActionByOption(optionName, config) { + let action; + let eventName; + let actionFunc; + + config = extend({}, config); + + const result = (...args) => { + if (!eventName) { + config = config || {}; + + if (typeof optionName !== 'string') { + throw errors.Error('E0008'); + } + + if (optionName.startsWith('on')) { + eventName = getEventName(optionName); + } + /// #DEBUG + if (!optionName.startsWith('on')) { + throw Error(`The '${optionName}' option name should start with 'on' prefix`); + } + /// #ENDDEBUG + + actionFunc = this.option(optionName); + } + + if (!action && !actionFunc && !config.beforeExecute && !config.afterExecute && !this._eventsStrategy.hasEvent(eventName)) { + return; + } + + if (!action) { + const { beforeExecute } = config; + config.beforeExecute = (...props) => { + beforeExecute && beforeExecute.apply(this, props); + this._eventsStrategy.fireEvent(eventName, props[0].args); + }; + action = this._createAction(actionFunc, config); + } + + // @ts-expect-error + if (Config().wrapActionsBeforeExecute) { + const beforeActionExecute = this.option('beforeActionExecute') || noop; + const wrappedAction = beforeActionExecute(this, action, config) || action; + return wrappedAction.apply(this, args); + } + + return action.apply(this, args); + }; + + // @ts-expect-error + if (Config().wrapActionsBeforeExecute) { + return result; + } + + const onActionCreated = this.option('onActionCreated') || noop; + + return onActionCreated(this, result, config) || result; + } + + on(eventName, eventHandler) { + this._eventsStrategy.on(eventName, eventHandler); + return this; + } + + off(eventName, eventHandler) { + this._eventsStrategy.off(eventName, eventHandler); + return this; + } + + hasActionSubscription(actionName) { + return !!this._options.silent(actionName) + || this._eventsStrategy.hasEvent(getEventName(actionName)); + } + + isOptionDeprecated(name) { + return this._options.isDeprecated(name); + } + + _setOptionWithoutOptionChange(name, value) { + this._cancelOptionChange = name; + this.option(name, value); + this._cancelOptionChange = false; + } + + _getOptionValue(name, context) { + const value = this.option(name); + + if (isFunction(value)) { + return value.bind(context)(); + } + + return value; + } + + option(...args) { + return this._options.option(...args); + } + + resetOption(name) { + this.beginUpdate(); + this._options.reset(name); + this.endUpdate(); + } +} diff --git a/packages/devextreme/js/__internal/core/widget/dom_component.ts b/packages/devextreme/js/__internal/core/widget/dom_component.ts new file mode 100644 index 000000000000..9174e38acea8 --- /dev/null +++ b/packages/devextreme/js/__internal/core/widget/dom_component.ts @@ -0,0 +1,540 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-plusplus */ +/* eslint-disable consistent-return */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable new-cap */ +/* eslint-disable no-void */ +/* eslint-disable no-return-assign */ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/prefer-optional-chain */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable no-multi-assign */ +/* eslint-disable max-len */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import config from '@js/core/config'; +import { getPublicElement } from '@js/core/element'; +import { cleanDataRecursive } from '@js/core/element_data'; +import errors from '@js/core/errors'; +import $ from '@js/core/renderer'; +import { TemplateManager } from '@js/core/template_manager'; +// @ts-expect-error +import { grep, noop } from '@js/core/utils/common'; +import { extend } from '@js/core/utils/extend'; +import { each } from '@js/core/utils/iterator'; +import { attachInstanceToElement, getInstanceByElement } from '@js/core/utils/public_component'; +import windowResizeCallbacks from '@js/core/utils/resize_callbacks'; +import { addShadowDomStyles } from '@js/core/utils/shadow_dom'; +import { isDefined, isFunction, isString } from '@js/core/utils/type'; +import { hasWindow } from '@js/core/utils/window'; +import { resize as resizeEvent, visibility as visibilityEvents } from '@js/events/short'; +import license, { peekValidationPerformed } from '@ts/core/license/license_validation'; + +import { Component } from './component'; + +class DOMComponent extends Component { + static _classCustomRules: any; + + private _customClass: any; + + private _$element: any; + + private _windowResizeCallBack: any; + + private _isHidden: any; + + private _requireRefresh: any; + + private _templateManager: any; + + static getInstance(element) { + return getInstanceByElement($(element), this); + } + + static defaultOptions(rule) { + this._classCustomRules = Object.hasOwnProperty.bind(this)('_classCustomRules') && this._classCustomRules ? this._classCustomRules : []; + this._classCustomRules.push(rule); + } + + _getDefaultOptions() { + return extend(super._getDefaultOptions(), { + + width: undefined, + + height: undefined, + + rtlEnabled: config().rtlEnabled, + + elementAttr: {}, + + disabled: false, + + integrationOptions: {}, + // @ts-expect-error + }, this._useTemplates() ? TemplateManager.createDefaultOptions() : {}); + } + + // @ts-expect-error + ctor(element, options) { + this._customClass = null; + + this._createElement(element); + attachInstanceToElement(this._$element, this, this._dispose); + + super.ctor(options); + const validationAlreadyPerformed = peekValidationPerformed(); + // @ts-expect-error + license.validateLicense(config().licenseKey); + if (!validationAlreadyPerformed && peekValidationPerformed()) { + config({ licenseKey: '' }); + } + } + + _createElement(element) { + this._$element = $(element); + } + + _getSynchronizableOptionsForCreateComponent() { + return ['rtlEnabled', 'disabled', 'templatesRenderAsynchronously']; + } + + _checkFunctionValueDeprecation(optionNames) { + if (!this.option('_ignoreFunctionValueDeprecation')) { + optionNames.forEach((optionName) => { + if (isFunction(this.option(optionName))) { + errors.log('W0017', optionName); + } + }); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _visibilityChanged(value: boolean) {} + + _dimensionChanged() {} + + _init() { + super._init(); + this._checkFunctionValueDeprecation([ + 'width', 'height', + 'maxHeight', 'maxWidth', + 'minHeight', 'minWidth', + 'popupHeight', 'popupWidth', + ]); + this._attachWindowResizeCallback(); + this._initTemplateManager(); + } + + _setOptionsByDevice(instanceCustomRules) { + // @ts-expect-error + super._setOptionsByDevice([].concat(this.constructor._classCustomRules || [], instanceCustomRules || [])); + } + + _isInitialOptionValue(name) { + // @ts-expect-error + const isCustomOption = this.constructor._classCustomRules + // @ts-expect-error + && Object.prototype.hasOwnProperty.call(this._convertRulesToOptions(this.constructor._classCustomRules), name); + + return !isCustomOption && super._isInitialOptionValue(name); + } + + _attachWindowResizeCallback() { + if (this._isDimensionChangeSupported()) { + const windowResizeCallBack = this._windowResizeCallBack = this._dimensionChanged.bind(this); + + windowResizeCallbacks.add(windowResizeCallBack); + } + } + + _isDimensionChangeSupported() { + return this._dimensionChanged !== DOMComponent.prototype._dimensionChanged; + } + + _renderComponent() { + addShadowDomStyles(this.$element()); + + this._initMarkup(); + + hasWindow() && this._render(); + } + + _initMarkup() { + const { rtlEnabled } = this.option() || {}; + + this._renderElementAttributes(); + this._toggleRTLDirection(rtlEnabled); + this._renderVisibilityChange(); + this._renderDimensions(); + } + + _render() { + this._attachVisibilityChangeHandlers(); + } + + _renderElementAttributes() { + const { elementAttr } = this.option() || {}; + const attributes = extend({}, elementAttr); + const classNames = attributes.class; + + delete attributes.class; + + this.$element() + .attr(attributes) + .removeClass(this._customClass) + .addClass(classNames); + + this._customClass = classNames; + } + + _renderVisibilityChange() { + if (this._isDimensionChangeSupported()) { + this._attachDimensionChangeHandlers(); + } + + if (this._isVisibilityChangeSupported()) { + const $element = this.$element(); + + $element.addClass('dx-visibility-change-handler'); + } + } + + _renderDimensions() { + const $element = this.$element(); + const element = $element.get(0); + const width = this._getOptionValue('width', element); + const height = this._getOptionValue('height', element); + + if (this._isCssUpdateRequired(element, height, width)) { + $element.css({ + width: width === null ? '' : width, + height: height === null ? '' : height, + }); + } + } + + _isCssUpdateRequired(element, height, width) { + return !!(isDefined(width) || isDefined(height) || element.style.width || element.style.height); + } + + _attachDimensionChangeHandlers() { + const $el = this.$element(); + const namespace = `${this.NAME}VisibilityChange`; + + resizeEvent.off($el, { namespace }); + resizeEvent.on($el, () => this._dimensionChanged(), { namespace }); + } + + _attachVisibilityChangeHandlers() { + if (this._isVisibilityChangeSupported()) { + const $el = this.$element(); + const namespace = `${this.NAME}VisibilityChange`; + + this._isHidden = !this._isVisible(); + visibilityEvents.off($el, { namespace }); + visibilityEvents.on( + $el, + () => this._checkVisibilityChanged('shown'), + () => this._checkVisibilityChanged('hiding'), + { namespace }, + ); + } + } + + _isVisible() { + const $element = this.$element(); + + return $element.is(':visible'); + } + + _checkVisibilityChanged(action) { + const isVisible = this._isVisible(); + + if (isVisible) { + if (action === 'hiding' && !this._isHidden) { + this._visibilityChanged(false); + this._isHidden = true; + } else if (action === 'shown' && this._isHidden) { + this._isHidden = false; + this._visibilityChanged(true); + } + } + } + + _isVisibilityChangeSupported() { + return this._visibilityChanged !== DOMComponent.prototype._visibilityChanged && hasWindow(); + } + + _clean() {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _modelByElement(element) { + const { modelByElement } = this.option(); + const $element = this.$element(); + + return modelByElement ? modelByElement($element) : undefined; + } + + _invalidate() { + if (this._isUpdateAllowed()) { + throw errors.Error('E0007'); + } + + this._requireRefresh = true; + } + + _refresh() { + this._clean(); + this._renderComponent(); + } + + _dispose() { + this._templateManager && this._templateManager.dispose(); + super._dispose(); + this._clean(); + this._detachWindowResizeCallback(); + } + + _detachWindowResizeCallback() { + if (this._isDimensionChangeSupported()) { + windowResizeCallbacks.remove(this._windowResizeCallBack); + } + } + + _toggleRTLDirection(rtl) { + const $element = this.$element(); + + $element.toggleClass('dx-rtl', rtl); + } + + _createComponent(element, component, config = {}) { + const synchronizableOptions = grep( + this._getSynchronizableOptionsForCreateComponent(), + (value) => !(value in config), + ); + + const { integrationOptions } = this.option(); + let { nestedComponentOptions } = this.option(); + + nestedComponentOptions = nestedComponentOptions || noop; + + const nestedComponentConfig = extend( + { integrationOptions }, + nestedComponentOptions(this), + ); + + synchronizableOptions.forEach((optionName) => nestedComponentConfig[optionName] = this.option(optionName)); + + this._extendConfig(config, nestedComponentConfig); + + let instance = void 0; + + if (isString(component)) { + const $element = $(element)[component](config); + + instance = $element[component]('instance'); + } else if (element) { + instance = component.getInstance(element); + + if (instance) { + // @ts-expect-error + instance.option(config); + } else { + instance = new component(element, config); + } + } + + if (instance) { + const optionChangedHandler = ({ name, value }) => { + if (synchronizableOptions.includes(name)) { + // @ts-expect-error + instance.option(name, value); + } + }; + + this.on('optionChanged', optionChangedHandler); + // @ts-expect-error + instance.on('disposing', () => this.off('optionChanged', optionChangedHandler)); + } + + return instance; + } + + _extendConfig(config, extendConfig) { + each(extendConfig, (key, value) => { + !Object.prototype.hasOwnProperty.call(config, key) && (config[key] = value); + }); + } + + _defaultActionConfig() { + const $element = this.$element(); + const context = this._modelByElement($element); + + return extend(super._defaultActionConfig(), { context }); + } + + _defaultActionArgs() { + const $element = this.$element(); + const model = this._modelByElement($element); + const element = this.element(); + + return extend(super._defaultActionArgs(), { element, model }); + } + + _optionChanged(args) { + switch (args.name) { + case 'width': + case 'height': + this._renderDimensions(); + break; + case 'rtlEnabled': + this._invalidate(); + break; + case 'elementAttr': + this._renderElementAttributes(); + break; + case 'disabled': + case 'integrationOptions': + break; + default: + super._optionChanged(args); + break; + } + } + + _removeAttributes(element) { + const attrs = element.attributes; + + for (let i = attrs.length - 1; i >= 0; i--) { + const attr = attrs[i]; + + if (attr) { + const { name } = attr; + + if (!name.indexOf('aria-') || name.indexOf('dx-') !== -1 + || name === 'role' || name === 'style' || name === 'tabindex') { + element.removeAttribute(name); + } + } + } + } + + _removeClasses(element) { + element.className = element.className + .split(' ') + .filter((cssClass) => cssClass.lastIndexOf('dx-', 0) !== 0) + .join(' '); + } + + _updateDOMComponent(renderRequired) { + if (renderRequired) { + this._renderComponent(); + } else if (this._requireRefresh) { + this._requireRefresh = false; + this._refresh(); + } + } + + endUpdate() { + const renderRequired = this._isInitializingRequired(); + + super.endUpdate(); + this._isUpdateAllowed() && this._updateDOMComponent(renderRequired); + } + + $element() { + return this._$element; + } + + element() { + const $element = this.$element(); + + return getPublicElement($element); + } + + dispose() { + const element = this.$element().get(0); + + cleanDataRecursive(element, true); + element.textContent = ''; + this._removeAttributes(element); + this._removeClasses(element); + } + + resetOption(optionName) { + super.resetOption(optionName); + + if (optionName === 'width' || optionName === 'height') { + const initialOption = this.initialOption(optionName); + + !isDefined(initialOption) && this.$element().css(optionName, ''); + } + } + + _getAnonymousTemplateName() { + return void 0; + } + + _initTemplateManager() { + if (this._templateManager || !this._useTemplates()) return void 0; + + const { integrationOptions = {} } = this.option(); + const { createTemplate } = integrationOptions; + + this._templateManager = new TemplateManager( + // @ts-expect-error + createTemplate, + this._getAnonymousTemplateName(), + ); + this._initTemplates(); + + return undefined; + } + + _initTemplates() { + const { templates, anonymousTemplateMeta } = this._templateManager.extractTemplates(this.$element()); + const anonymousTemplate = this.option(`integrationOptions.templates.${anonymousTemplateMeta.name}`); + + templates.forEach(({ name, template }) => { + this._options.silent(`integrationOptions.templates.${name}`, template); + }); + + if (anonymousTemplateMeta.name && !anonymousTemplate) { + this._options.silent(`integrationOptions.templates.${anonymousTemplateMeta.name}`, anonymousTemplateMeta.template); + this._options.silent('_hasAnonymousTemplateContent', true); + } + } + + _getTemplateByOption(optionName) { + return this._getTemplate(this.option(optionName)); + } + + _getTemplate(templateSource) { + const templates = this.option('integrationOptions.templates'); + const isAsyncTemplate = this.option('templatesRenderAsynchronously'); + const skipTemplates = this.option('integrationOptions.skipTemplates'); + + return this._templateManager.getTemplate( + templateSource, + templates, + { + isAsyncTemplate, + skipTemplates, + }, + this, + ); + } + + _saveTemplate(name, template) { + this._setOptionWithoutOptionChange( + `integrationOptions.templates.${name}`, + this._templateManager._createTemplate(template), + ); + } + + _useTemplates() { + return true; + } +} + +export default DOMComponent; diff --git a/packages/devextreme/js/__internal/core/widget/widget.ts b/packages/devextreme/js/__internal/core/widget/widget.ts new file mode 100644 index 000000000000..922258b428cf --- /dev/null +++ b/packages/devextreme/js/__internal/core/widget/widget.ts @@ -0,0 +1,628 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable consistent-return */ +/* eslint-disable @typescript-eslint/no-invalid-this */ +/* eslint-disable no-void */ +/* eslint-disable max-len */ +/* eslint-disable @typescript-eslint/prefer-optional-chain */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable no-param-reassign */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import '@js/events/click'; +import '@js/events/core/emitter.feedback'; +import '@js/events/hover'; + +import Action from '@js/core/action'; +import devices from '@js/core/devices'; +import $ from '@js/core/renderer'; +import { deferRender } from '@js/core/utils/common'; +import { extend } from '@js/core/utils/extend'; +import { each } from '@js/core/utils/iterator'; +import { isDefined, isPlainObject } from '@js/core/utils/type'; +import { compare as compareVersions } from '@js/core/utils/version'; +import { + active, focus, hover, keyboard, +} from '@js/events/short'; +import { focusable as focusableSelector } from '@js/ui/widget/selectors'; + +import DOMComponent from './dom_component'; + +function setAttribute(name, value, target) { + name = name === 'role' || name === 'id' ? name : `aria-${name}`; + value = isDefined(value) ? value.toString() : null; + + target.attr(name, value); +} + +class Widget extends DOMComponent { + private readonly _feedbackHideTimeout = 400; + + private readonly _feedbackShowTimeout = 30; + + private _contentReadyAction: any; + + private readonly _activeStateUnit: any; + + private _keyboardListenerId: any; + + private _isReady: any; + + static getOptionsFromContainer({ name, fullName, value }) { + let options = {}; + + if (name === fullName) { + options = value; + } else { + const option = fullName.split('.').pop(); + + options[option] = value; + } + + return options; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _supportedKeys(event?) { + return {}; + } + + _getDefaultOptions() { + return extend(super._getDefaultOptions(), { + hoveredElement: null, + isActive: false, + + disabled: false, + + visible: true, + + hint: undefined, + + activeStateEnabled: false, + + onContentReady: null, + + hoverStateEnabled: false, + + focusStateEnabled: false, + + tabIndex: 0, + + accessKey: undefined, + + onFocusIn: null, + + onFocusOut: null, + onKeyboardHandled: null, + ignoreParentReadOnly: false, + useResizeObserver: true, + }); + } + + _defaultOptionsRules() { + return super._defaultOptionsRules().concat([{ + device() { + const device = devices.real(); + const { platform } = device; + const { version } = device; + return platform === 'ios' && compareVersions(version, '13.3') <= 0; + }, + options: { + useResizeObserver: false, + }, + }]); + } + + _init() { + super._init(); + this._initContentReadyAction(); + } + + _innerWidgetOptionChanged(innerWidget, args) { + const options = Widget.getOptionsFromContainer(args); + innerWidget && innerWidget.option(options); + this._options.cache(args.name, options); + } + + _bindInnerWidgetOptions(innerWidget, optionsContainer) { + const syncOptions = () => this._options.silent(optionsContainer, extend({}, innerWidget.option())); + + syncOptions(); + innerWidget.on('optionChanged', syncOptions); + } + + _getAriaTarget() { + return this._focusTarget(); + } + + _initContentReadyAction() { + this._contentReadyAction = this._createActionByOption('onContentReady', { + excludeValidators: ['disabled', 'readOnly'], + }); + } + + _initMarkup() { + const { disabled, visible } = this.option(); + + this.$element().addClass('dx-widget'); + + this._toggleDisabledState(disabled); + this._toggleVisibility(visible); + this._renderHint(); + this._isFocusable() && this._renderFocusTarget(); + + super._initMarkup(); + } + + _render() { + super._render(); + + this._renderContent(); + this._renderFocusState(); + this._attachFeedbackEvents(); + this._attachHoverEvents(); + this._toggleIndependentState(); + } + + _renderHint() { + const { hint } = this.option(); + + this.$element().attr('title', hint || null); + } + + _renderContent() { + deferRender(() => (!this._disposed ? this._renderContentImpl() : void 0)) + // @ts-expect-error + .done(() => (!this._disposed ? this._fireContentReadyAction() : void 0)); + } + + _renderContentImpl() {} + + _fireContentReadyAction() { + return deferRender(() => this._contentReadyAction()); + } + + _dispose() { + this._contentReadyAction = null; + this._detachKeyboardEvents(); + + super._dispose(); + } + + _resetActiveState() { + this._toggleActiveState(this._eventBindingTarget(), false); + } + + _clean() { + this._cleanFocusState(); + this._resetActiveState(); + super._clean(); + this.$element().empty(); + } + + _toggleVisibility(visible) { + this.$element().toggleClass('dx-state-invisible', !visible); + } + + _renderFocusState() { + this._attachKeyboardEvents(); + + if (this._isFocusable()) { + this._renderFocusTarget(); + this._attachFocusEvents(); + this._renderAccessKey(); + } + } + + _renderAccessKey() { + const $el = this._focusTarget(); + const { accessKey } = this.option(); + + $el.attr('accesskey', accessKey); + } + + _isFocusable() { + const { focusStateEnabled, disabled } = this.option(); + + return focusStateEnabled && !disabled; + } + + _eventBindingTarget() { + return this.$element(); + } + + _focusTarget() { + return this._getActiveElement(); + } + + _isFocusTarget(element) { + const focusTargets = $(this._focusTarget()).toArray(); + return focusTargets.includes(element); + } + + _findActiveTarget($element) { + return $element.find(this._activeStateUnit).not('.dx-state-disabled'); + } + + _getActiveElement() { + const activeElement = this._eventBindingTarget(); + + if (this._activeStateUnit) { + return this._findActiveTarget(activeElement); + } + + return activeElement; + } + + _renderFocusTarget() { + const { tabIndex } = this.option(); + + this._focusTarget().attr('tabIndex', tabIndex); + } + + _keyboardEventBindingTarget() { + return this._eventBindingTarget(); + } + + _refreshFocusEvent() { + this._detachFocusEvents(); + this._attachFocusEvents(); + } + + _focusEventTarget() { + return this._focusTarget(); + } + + _focusInHandler(event) { + if (!event.isDefaultPrevented()) { + this._createActionByOption('onFocusIn', { + beforeExecute: () => this._updateFocusState(event, true), + excludeValidators: ['readOnly'], + })({ event }); + } + } + + _focusOutHandler(event) { + if (!event.isDefaultPrevented()) { + this._createActionByOption('onFocusOut', { + beforeExecute: () => this._updateFocusState(event, false), + excludeValidators: ['readOnly', 'disabled'], + })({ event }); + } + } + + _updateFocusState({ target }, isFocused) { + if (this._isFocusTarget(target)) { + this._toggleFocusClass(isFocused, $(target)); + } + } + + _toggleFocusClass(isFocused, $element?) { + const $focusTarget = $element && $element.length ? $element : this._focusTarget(); + + $focusTarget.toggleClass('dx-state-focused', isFocused); + } + + _hasFocusClass(element?) { + const $focusTarget = $(element || this._focusTarget()); + + return $focusTarget.hasClass('dx-state-focused'); + } + + _isFocused() { + return this._hasFocusClass(); + } + + _getKeyboardListeners(): any[] { + return []; + } + + _attachKeyboardEvents() { + this._detachKeyboardEvents(); + + const { focusStateEnabled, onKeyboardHandled } = this.option(); + const hasChildListeners = this._getKeyboardListeners().length; + const hasKeyboardEventHandler = !!onKeyboardHandled; + const shouldAttach = focusStateEnabled || hasChildListeners || hasKeyboardEventHandler; + + if (shouldAttach) { + this._keyboardListenerId = keyboard.on( + this._keyboardEventBindingTarget(), + this._focusTarget(), + (opts) => this._keyboardHandler(opts), + ); + } + } + + _keyboardHandler(options, onlyChildProcessing?) { + if (!onlyChildProcessing) { + const { originalEvent, keyName, which } = options; + const keys = this._supportedKeys(originalEvent); + const func = keys[keyName] || keys[which]; + + if (func !== undefined) { + const handler = func.bind(this); + const result = handler(originalEvent, options); + + if (!result) { + return false; + } + } + } + + const keyboardListeners = this._getKeyboardListeners(); + const { onKeyboardHandled } = this.option(); + + keyboardListeners.forEach((listener) => listener && listener._keyboardHandler(options)); + + onKeyboardHandled && onKeyboardHandled(options); + + return true; + } + + _refreshFocusState() { + this._cleanFocusState(); + this._renderFocusState(); + } + + _cleanFocusState() { + const $element = this._focusTarget(); + + $element.removeAttr('tabIndex'); + this._toggleFocusClass(false); + this._detachFocusEvents(); + this._detachKeyboardEvents(); + } + + _detachKeyboardEvents() { + keyboard.off(this._keyboardListenerId); + this._keyboardListenerId = null; + } + + _attachHoverEvents() { + const { hoverStateEnabled } = this.option(); + const selector = this._activeStateUnit; + const namespace = 'UIFeedback'; + const $el = this._eventBindingTarget(); + + hover.off($el, { selector, namespace }); + + if (hoverStateEnabled) { + hover.on($el, new Action(({ event, element }) => { + this._hoverStartHandler(event); + this.option('hoveredElement', $(element)); + }, { excludeValidators: ['readOnly'] }), (event) => { + this.option('hoveredElement', null); + this._hoverEndHandler(event); + }, { selector, namespace }); + } + } + + _attachFeedbackEvents() { + const { activeStateEnabled } = this.option(); + const selector = this._activeStateUnit; + const namespace = 'UIFeedback'; + const $el = this._eventBindingTarget(); + + active.off($el, { namespace, selector }); + + if (activeStateEnabled) { + active.on( + $el, + new Action(({ event, element }) => this._toggleActiveState($(element), true, event)), + new Action( + ({ event, element }) => this._toggleActiveState($(element), false, event), + { excludeValidators: ['disabled', 'readOnly'] }, + ), + { + showTimeout: this._feedbackShowTimeout, + hideTimeout: this._feedbackHideTimeout, + selector, + namespace, + }, + ); + } + } + + _detachFocusEvents() { + const $el = this._focusEventTarget(); + + focus.off($el, { namespace: `${this.NAME}Focus` }); + } + + _attachFocusEvents() { + const $el = this._focusEventTarget(); + + focus.on( + $el, + (e) => this._focusInHandler(e), + (e) => this._focusOutHandler(e), + { + namespace: `${this.NAME}Focus`, + // @ts-expect-error + isFocusable: (index, el) => $(el).is(focusableSelector), + }, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _hoverStartHandler(event) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _hoverEndHandler(event) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _toggleActiveState($element, value, event?) { + this.option('isActive', value); + $element.toggleClass('dx-state-active', value); + } + + _updatedHover() { + const hoveredElement = this._options.silent('hoveredElement'); + + this._hover(hoveredElement, hoveredElement); + } + + _findHoverTarget($el) { + return $el && $el.closest(this._activeStateUnit || this._eventBindingTarget()); + } + + _hover($el, $previous) { + const { hoverStateEnabled, disabled, isActive } = this.option(); + + $previous = this._findHoverTarget($previous); + $previous && $previous.toggleClass('dx-state-hover', false); + + if ($el && hoverStateEnabled && !disabled && !isActive) { + const newHoveredElement = this._findHoverTarget($el); + + newHoveredElement && newHoveredElement.toggleClass('dx-state-hover', true); + } + } + + _toggleDisabledState(value) { + this.$element().toggleClass('dx-state-disabled', Boolean(value)); + this.setAria('disabled', value || undefined); + } + + _toggleIndependentState() { + this.$element().toggleClass('dx-state-independent', this.option('ignoreParentReadOnly')); + } + + _setWidgetOption(widgetName, args) { + if (!this[widgetName]) { + return; + } + + if (isPlainObject(args[0])) { + each(args[0], (option, value) => this._setWidgetOption(widgetName, [option, value])); + + return; + } + + const optionName = args[0]; + let value = args[1]; + + if (args.length === 1) { + value = this.option(optionName); + } + + const widgetOptionMap = this[`${widgetName}OptionMap`]; + + this[widgetName].option(widgetOptionMap ? widgetOptionMap(optionName) : optionName, value); + } + + _optionChanged(args) { + const { name, value, previousValue } = args; + + switch (name) { + case 'disabled': + this._toggleDisabledState(value); + this._updatedHover(); + this._refreshFocusState(); + break; + case 'hint': + this._renderHint(); + break; + case 'ignoreParentReadOnly': + this._toggleIndependentState(); + break; + case 'activeStateEnabled': + this._attachFeedbackEvents(); + break; + case 'hoverStateEnabled': + this._attachHoverEvents(); + this._updatedHover(); + break; + case 'tabIndex': + case 'focusStateEnabled': + this._refreshFocusState(); + break; + case 'onFocusIn': + case 'onFocusOut': + case 'useResizeObserver': + break; + case 'accessKey': + this._renderAccessKey(); + break; + case 'hoveredElement': + this._hover(value, previousValue); + break; + case 'isActive': + this._updatedHover(); + break; + case 'visible': + this._toggleVisibility(value); + if (this._isVisibilityChangeSupported()) { + // TODO hiding works wrong + this._checkVisibilityChanged(value ? 'shown' : 'hiding'); + } + break; + case 'onKeyboardHandled': + this._attachKeyboardEvents(); + break; + case 'onContentReady': + this._initContentReadyAction(); + break; + default: + super._optionChanged(args); + } + } + + _isVisible() { + const { visible } = this.option(); + + return super._isVisible() && visible; + } + + beginUpdate() { + this._ready(false); + super.beginUpdate(); + } + + endUpdate() { + super.endUpdate(); + + if (this._initialized) { + this._ready(true); + } + } + + _ready(value?) { + if (arguments.length === 0) { + return this._isReady; + } + + this._isReady = value; + } + + setAria(...args) { + if (!isPlainObject(args[0])) { + setAttribute(args[0], args[1], args[2] || this._getAriaTarget()); + } else { + const target = args[1] || this._getAriaTarget(); + + each(args[0], (name, value) => setAttribute(name, value, target)); + } + } + + isReady() { + return this._ready(); + } + + repaint() { + this._refresh(); + } + + focus() { + focus.trigger(this._focusTarget()); + } + + registerKeyHandler(key, handler) { + const currentKeys = this._supportedKeys(); + + this._supportedKeys = () => extend(currentKeys, { [key]: handler }); + } +} + +export default Widget; diff --git a/packages/devextreme/js/__internal/grids/grid_core/editing/const.ts b/packages/devextreme/js/__internal/grids/grid_core/editing/const.ts index 70eb6fa5aab4..86734be0b393 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/editing/const.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/editing/const.ts @@ -97,6 +97,7 @@ export const ADD_ROW_BUTTON_CLASS = 'addrow-button'; export const DROPDOWN_EDITOR_OVERLAY_CLASS = 'dx-dropdowneditor-overlay'; export const DATA_ROW_CLASS = 'dx-data-row'; export const ROW_REMOVED = 'dx-row-removed'; +export const FILTER_ROW_CLASS = 'filter-row'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const isRenovatedScrollable = !!(Scrollable as any).IS_RENOVATED_WIDGET; diff --git a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts index daad6797bd04..057a98050234 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts @@ -37,6 +37,7 @@ import { EDIT_MODE_FORM, EDIT_MODE_ROW, EDITOR_CELL_CLASS, + FILTER_ROW_CLASS, FOCUSABLE_ELEMENT_SELECTOR, ROW_CLASS, } from '../editing/const'; @@ -2842,11 +2843,23 @@ const editing = (Base: ModuleType) => class EditingController const result = super.closeEditCell.apply(this, arguments as any); - keyboardNavigation._updateFocus(); + const $focusedElement = this._getFocusedElement(); + const isFilterCell = !!$focusedElement.closest(`.${this.addWidgetPrefix(FILTER_ROW_CLASS)}`).length; + + if (!isFilterCell) { + keyboardNavigation._updateFocus(); + } return result; } + private _getFocusedElement() { + const $element = $(this.component.element?.()); + const $focusedElement = $element.find(':focus'); + + return $focusedElement; + } + protected _delayedInputFocus() { this._keyboardNavigationController._isNeedScroll = true; super._delayedInputFocus.apply(this, arguments as any); diff --git a/packages/devextreme/js/__internal/grids/grid_core/pager/m_pager.ts b/packages/devextreme/js/__internal/grids/grid_core/pager/m_pager.ts index a787f80a80b6..a6ee15bb3224 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/pager/m_pager.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/pager/m_pager.ts @@ -3,7 +3,6 @@ import { hasWindow } from '@js/core/utils/window'; import messageLocalization from '@js/localization/message'; import Pager from '@js/ui/pager'; -import type { PagerProps } from '../../../pager/common/pager_props'; import { View } from '../m_modules'; const PAGER_CLASS = 'pager'; @@ -84,7 +83,7 @@ export class PagerView extends View { const pagerOptions = that.option('pager') ?? {}; const dataController = that.getController('data'); const keyboardController = that.getController('keyboardNavigation'); - const options: PagerProps = { + const options: any = { maxPagesCount: MAX_PAGES_COUNT, pageIndex: getPageIndex(dataController), pageCount: dataController.pageCount(), diff --git a/packages/devextreme/js/__internal/grids/grid_core/selection/m_selection.ts b/packages/devextreme/js/__internal/grids/grid_core/selection/m_selection.ts index cf2ca7aefc46..ed2a474e10de 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/selection/m_selection.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/selection/m_selection.ts @@ -217,6 +217,7 @@ export class SelectionController extends modules.Controller { selectionFilter: this.option('selectionFilter'), ignoreDisabledItems: true, isVirtualPaging: virtualPaging, + sensitivity: this.option('selection.sensitivity'), allowLoadByRange() { const hasGroupColumns = columnsController.getGroupColumns().length > 0; return virtualPaging && !legacyScrollingMode && !hasGroupColumns && allowSelectAll && !deferred; @@ -407,6 +408,8 @@ export class SelectionController extends modules.Controller { public optionChanged(args) { super.optionChanged(args); + const selectionOptionsExists = !!this._selection?.options; + // eslint-disable-next-line default-case switch (args.name) { case 'selection': { @@ -414,6 +417,10 @@ export class SelectionController extends modules.Controller { this.init(); + if (selectionOptionsExists && args.fullName === 'selection.sensitivity') { + this._selection.options.sensitivity = args.value; + } + if (args.fullName !== 'selection.showCheckBoxesMode') { const selectionMode = this._selectionMode; let selectedRowKeys: any = this.option('selectedRowKeys'); diff --git a/packages/devextreme/js/__internal/pager/common/pager_props.ts b/packages/devextreme/js/__internal/pager/common/pager_props.ts index 507cf8770580..dde726128150 100644 --- a/packages/devextreme/js/__internal/pager/common/pager_props.ts +++ b/packages/devextreme/js/__internal/pager/common/pager_props.ts @@ -5,14 +5,16 @@ export interface PagerProps extends BasePagerProps { [key: string]: unknown; pageSize: number; pageIndex: number; - pageIndexChanged: EventCallback; - pageSizeChanged: EventCallback; + pageIndexChanged?: EventCallback; + pageSizeChanged?: EventCallback; + pageIndexChangedInternal: EventCallback; + pageSizeChangedInternal: EventCallback; } export const PagerDefaultProps: PagerProps = { ...BasePagerDefaultProps, pageSize: 5, pageIndex: 1, - pageIndexChanged: () => { }, - pageSizeChanged: () => { }, + pageIndexChangedInternal: () => { }, + pageSizeChangedInternal: () => { }, }; diff --git a/packages/devextreme/js/__internal/pager/content.tsx b/packages/devextreme/js/__internal/pager/content.tsx index 76de111798b5..ebd2b6dbbdae 100644 --- a/packages/devextreme/js/__internal/pager/content.tsx +++ b/packages/devextreme/js/__internal/pager/content.tsx @@ -181,7 +181,7 @@ export class PagerContent extends InfernoComponent { showPageSizes, pageSizesRef, pageSize, - pageSizeChanged, + pageSizeChangedInternal, pageSizes, infoTextRef, infoText, @@ -191,9 +191,10 @@ export class PagerContent extends InfernoComponent { pagesRef, hasKnownLastPage, maxPagesCount, - pageIndexChanged, + pageIndexChangedInternal, pagesCountText, showNavigationButtons, + style, } = this.props; return ( @@ -203,13 +204,14 @@ export class PagerContent extends InfernoComponent { classes={this.getClasses()} visible={visible} aria={this.getAria()} + {...style as []} > {showPageSizes && ( )} @@ -238,7 +240,7 @@ export class PagerContent extends InfernoComponent { maxPagesCount={maxPagesCount} pageCount={pageCount} pageIndex={pageIndex} - pageIndexChanged={pageIndexChanged} + pageIndexChangedInternal={pageIndexChangedInternal} pagesCountText={pagesCountText} showNavigationButtons={showNavigationButtons} totalCount={totalCount} diff --git a/packages/devextreme/js/__internal/pager/page_size/large.tsx b/packages/devextreme/js/__internal/pager/page_size/large.tsx index 30a9c7b548a9..fd7f1ce7c95f 100644 --- a/packages/devextreme/js/__internal/pager/page_size/large.tsx +++ b/packages/devextreme/js/__internal/pager/page_size/large.tsx @@ -17,12 +17,12 @@ export interface PageSizeLargeProps { } // eslint-disable-next-line @typescript-eslint/no-type-alias -type PageSizeLargePropsType = Pick & PageSizeLargeProps; +type PageSizeLargePropsType = Pick & PageSizeLargeProps; export const PageSizeLargeDefaultProps: PageSizeLargePropsType = { pageSizes: [], pageSize: PagerDefaultProps.pageSize, - pageSizeChanged: PagerDefaultProps.pageSizeChanged, + pageSizeChangedInternal: PagerDefaultProps.pageSizeChangedInternal, }; export class PageSizeLarge extends BaseInfernoComponent { @@ -83,7 +83,7 @@ export class PageSizeLarge extends BaseInfernoComponent onPageSizeChange(processedPageSize): () => void { return () => { - this.props.pageSizeChanged(processedPageSize); + this.props.pageSizeChangedInternal(processedPageSize); return this.props.pageSize; }; } @@ -91,7 +91,7 @@ export class PageSizeLarge extends BaseInfernoComponent componentWillUpdate(nextProps: PageSizeLargePropsType): void { const componentChanged = this.props.pageSize !== nextProps.pageSize || this.props.pageSizes !== nextProps.pageSizes - || this.props.pageSizeChanged !== nextProps.pageSizeChanged; + || this.props.pageSizeChangedInternal !== nextProps.pageSizeChangedInternal; if (componentChanged) { this.__getterCache.pageSizesText = undefined; } diff --git a/packages/devextreme/js/__internal/pager/page_size/selector.tsx b/packages/devextreme/js/__internal/pager/page_size/selector.tsx index 9f6053c432d7..d1f01a273909 100644 --- a/packages/devextreme/js/__internal/pager/page_size/selector.tsx +++ b/packages/devextreme/js/__internal/pager/page_size/selector.tsx @@ -24,12 +24,12 @@ export interface PageSizeSelectorProps { } // eslint-disable-next-line @typescript-eslint/no-type-alias -type PageSizeSelectorPropsType = Pick & PageSizeSelectorProps; +type PageSizeSelectorPropsType = Pick & PageSizeSelectorProps; const PageSizeSelectorDefaultProps: PageSizeSelectorPropsType = { isLargeDisplayMode: true, pageSize: PagerDefaultProps.pageSize, - pageSizeChanged: PagerDefaultProps.pageSizeChanged, + pageSizeChangedInternal: PagerDefaultProps.pageSizeChangedInternal, pageSizes: PagerDefaultProps.pageSizes, }; @@ -91,7 +91,7 @@ export class PageSizeSelector extends InfernoComponent )} {!isLargeDisplayMode && ( @@ -108,7 +108,7 @@ export class PageSizeSelector extends InfernoComponent )} diff --git a/packages/devextreme/js/__internal/pager/page_size/small.tsx b/packages/devextreme/js/__internal/pager/page_size/small.tsx index 104716417d7c..3f1fdfa12ccf 100644 --- a/packages/devextreme/js/__internal/pager/page_size/small.tsx +++ b/packages/devextreme/js/__internal/pager/page_size/small.tsx @@ -23,12 +23,12 @@ const PagerSmallDefaultProps: PagerSmallProps = { pageSizes: [], }; -type PageSizeSmallPropsType = PagerSmallProps & Pick; +type PageSizeSmallPropsType = PagerSmallProps & Pick; const PageSizeSmallDefaultProps: PageSizeSmallPropsType = { ...PagerSmallDefaultProps, pageSize: PagerDefaultProps.pageSize, - pageSizeChanged: PagerDefaultProps.pageSizeChanged, + pageSizeChangedInternal: PagerDefaultProps.pageSizeChangedInternal, }; export class PageSizeSmall extends InfernoComponent { @@ -52,7 +52,7 @@ export class PageSizeSmall extends InfernoComponent { this.props, this.state.minWidth, this.props.pageSize, - this.props.pageSizeChanged, + this.props.pageSizeChangedInternal, this.props.pageSizes, this.props.inputAttr, ]; @@ -64,7 +64,7 @@ export class PageSizeSmall extends InfernoComponent { this.props, this.state.minWidth, this.props.pageSize, - this.props.pageSizeChanged, + this.props.pageSizeChangedInternal, this.props.pageSizes, this.props.inputAttr, ]; @@ -89,7 +89,7 @@ export class PageSizeSmall extends InfernoComponent { inputAttr, pageSizes, pageSize, - pageSizeChanged, + pageSizeChangedInternal, } = this.props; return ( { valueExpr="value" dataSource={pageSizes} value={pageSize} - valueChange={pageSizeChanged} + valueChange={pageSizeChangedInternal} width={this.getWidth()} inputAttr={inputAttr} /> diff --git a/packages/devextreme/js/__internal/pager/pager.tsx b/packages/devextreme/js/__internal/pager/pager.tsx index c8cbda75c560..7f616007e22a 100644 --- a/packages/devextreme/js/__internal/pager/pager.tsx +++ b/packages/devextreme/js/__internal/pager/pager.tsx @@ -13,20 +13,20 @@ export class Pager extends InfernoWrapperComponent { constructor(props) { super(props); - this.pageIndexChanged = this.pageIndexChanged.bind(this); - this.pageSizeChanged = this.pageSizeChanged.bind(this); + this.pageIndexChangedInternal = this.pageIndexChangedInternal.bind(this); + this.pageSizeChangedInternal = this.pageSizeChangedInternal.bind(this); } createEffects(): InfernoEffect[] { return [createReRenderEffect()]; } - pageIndexChanged(newPageIndex: number): void { + pageIndexChangedInternal(newPageIndex: number): void { const newValue = this.props.gridCompatibility ? newPageIndex + 1 : newPageIndex; this.setState(() => ({ pageIndex: newValue, })); - this.props.pageIndexChanged(newValue); + this.props.pageIndexChangedInternal(newValue); } getPageIndex(): number { @@ -36,11 +36,11 @@ export class Pager extends InfernoWrapperComponent { return this.props.pageIndex; } - pageSizeChanged(newPageSize: number): void { + pageSizeChangedInternal(newPageSize: number): void { this.setState(() => ({ pageSize: newPageSize, })); - this.props.pageSizeChanged(newPageSize); + this.props.pageSizeChangedInternal(newPageSize); } getClassName(): string | undefined { @@ -58,8 +58,9 @@ export class Pager extends InfernoWrapperComponent { ...this.props, className: this.getClassName(), pageIndex: this.getPageIndex(), - pageIndexChanged: (pageIndex: number): void => this.pageIndexChanged(pageIndex), - pageSizeChanged: (pageSize: number): void => this.pageSizeChanged(pageSize), + // eslint-disable-next-line max-len + pageIndexChangedInternal: (pageIndex: number): void => this.pageIndexChangedInternal(pageIndex), + pageSizeChangedInternal: (pageSize: number): void => this.pageSizeChangedInternal(pageSize), }; } diff --git a/packages/devextreme/js/__internal/pager/pages/large.tsx b/packages/devextreme/js/__internal/pager/pages/large.tsx index 49bfc3005143..0f1c5e9c9169 100644 --- a/packages/devextreme/js/__internal/pager/pages/large.tsx +++ b/packages/devextreme/js/__internal/pager/pages/large.tsx @@ -30,13 +30,13 @@ type DelimiterType = 'none' | 'low' | 'high' | 'both'; interface PageIndexes extends Array {} // eslint-disable-next-line @typescript-eslint/no-type-alias -type PagesLargePropsType = Pick; +type PagesLargePropsType = Pick; const PagesLargeDefaultProps: PagesLargePropsType = { maxPagesCount: PagerDefaultProps.maxPagesCount, pageCount: PagerDefaultProps.pageCount, pageIndex: PagerDefaultProps.pageIndex, - pageIndexChanged: PagerDefaultProps.pageIndexChanged, + pageIndexChangedInternal: PagerDefaultProps.pageIndexChangedInternal, }; function getDelimiterType( @@ -180,7 +180,7 @@ export class PagesLarge extends BaseInfernoComponent { } onPageClick(pageIndex) { - this.props.pageIndexChanged(pageIndex); + this.props.pageIndexChangedInternal(pageIndex); } getPageIndexes() { diff --git a/packages/devextreme/js/__internal/pager/pages/page_index_selector.tsx b/packages/devextreme/js/__internal/pager/pages/page_index_selector.tsx index b5dcb2f4019a..27234d8ca0be 100644 --- a/packages/devextreme/js/__internal/pager/pages/page_index_selector.tsx +++ b/packages/devextreme/js/__internal/pager/pages/page_index_selector.tsx @@ -43,7 +43,7 @@ type PageIndexSelectorPropsType = Pick )} {!isLargeDisplayMode && ( )} diff --git a/packages/devextreme/js/__internal/pager/pages/small.tsx b/packages/devextreme/js/__internal/pager/pages/small.tsx index 55bdead19a0a..d13ee4a6e191 100644 --- a/packages/devextreme/js/__internal/pager/pages/small.tsx +++ b/packages/devextreme/js/__internal/pager/pages/small.tsx @@ -22,13 +22,13 @@ export interface PagerSmallProps { } // eslint-disable-next-line @typescript-eslint/no-type-alias -type PagerSmallPropsType = Pick & PagerSmallProps; +type PagerSmallPropsType = Pick & PagerSmallProps; export const PagerSmallDefaultProps: PagerSmallPropsType = { inputAttr: { 'aria-label': messageLocalization.format('dxPager-ariaPageNumber') }, pageIndex: PagerDefaultProps.pageIndex, pageCount: PagerDefaultProps.pageCount, - pageIndexChanged: PagerDefaultProps.pageIndexChanged, + pageIndexChangedInternal: PagerDefaultProps.pageIndexChangedInternal, }; export class PagesSmall extends InfernoComponent { @@ -79,11 +79,11 @@ export class PagesSmall extends InfernoComponent { } selectLastPageIndex(): void { - this.props.pageIndexChanged(this.props.pageCount - 1); + this.props.pageIndexChangedInternal(this.props.pageCount - 1); } valueChange(value): void { - this.props.pageIndexChanged(value - 1); + this.props.pageIndexChangedInternal(value - 1); } render(): JSX.Element { diff --git a/packages/devextreme/js/__internal/pager/resizable_container.tsx b/packages/devextreme/js/__internal/pager/resizable_container.tsx index 314c67cb065b..d90a504cee1f 100644 --- a/packages/devextreme/js/__internal/pager/resizable_container.tsx +++ b/packages/devextreme/js/__internal/pager/resizable_container.tsx @@ -50,7 +50,7 @@ function getElementsWidth({ export interface ResizableContainerProps { pagerProps: PagerProps; - contentTemplate: JSXTemplate; + contentTemplate: JSXTemplate; } export const ResizableContainerDefaultProps = { @@ -145,9 +145,9 @@ export class ResizableContainer extends InfernoComponent { - const backgroundColor = getOverflowIndicatorColor(color, itemColors); - if (backgroundColor) { - $button.css('backgroundColor', backgroundColor); - } - }); - } - _setPosition(element, position) { move(element, { top: position.top, diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index aabe8f80d0b8..d399317ce3cc 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -192,7 +192,7 @@ class Scheduler extends Widget { _appointmentPopup: any; - _compactAppointmentsHelper: any; + _compactAppointmentsHelper!: CompactAppointmentsHelper; _asyncTemplatesTimers!: any[]; diff --git a/packages/devextreme/js/__internal/ui/chat/chat.ts b/packages/devextreme/js/__internal/ui/chat/chat.ts index 4a80519098d7..7d277e4b4857 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat.ts @@ -1,26 +1,29 @@ import registerComponent from '@js/core/component_registrator'; import Guid from '@js/core/guid'; +import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; -import type { - Message, MessageSendEvent, Properties, User, -} from '@js/ui/chat'; +import type { Message, MessageSendEvent, Properties } from '@js/ui/chat'; import Widget from '../widget'; import ChatHeader from './chat_header'; -import type { MessageBoxProperties } from './chat_message_box'; +import type { + MessageBoxProperties, + MessageSendEvent as MessageBoxMessageSendEvent, +} from './chat_message_box'; import MessageBox from './chat_message_box'; import MessageList from './chat_message_list'; const CHAT_CLASS = 'dx-chat'; +const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input'; class Chat extends Widget { - _chatHeader?: ChatHeader; + _chatHeader!: ChatHeader; - _messageBox?: MessageBox; + _messageBox!: MessageBox; - _messageList?: MessageList; + _messageList!: MessageList; - _messageSendAction?: (e: MessageSendEvent) => void; + _messageSendAction?: (e: Partial) => void; _getDefaultOptions(): Properties { return { @@ -49,16 +52,17 @@ class Chat extends Widget { } _renderHeader(): void { - const { title } = this.option(); + const { title = '' } = this.option(); const $header = $('
').appendTo(this.element()); - // @ts-expect-error - this._chatHeader = this._createComponent($header, ChatHeader, { title }); + this._chatHeader = this._createComponent($header, ChatHeader, { + title, + }); } _renderMessageList(): void { - const { items, user } = this.option(); + const { items = [], user } = this.option(); const currentUserId = user?.id; const $messageList = $('
').appendTo(this.element()); @@ -70,9 +74,18 @@ class Chat extends Widget { } _renderMessageBox(): void { + const { + activeStateEnabled, + focusStateEnabled, + hoverStateEnabled, + } = this.option(); + const $messageBox = $('
').appendTo(this.element()); const configuration: MessageBoxProperties = { + activeStateEnabled, + focusStateEnabled, + hoverStateEnabled, onMessageSend: (e) => { this._messageSendHandler(e); }, @@ -88,7 +101,7 @@ class Chat extends Widget { ); } - _messageSendHandler(e: MessageSendEvent): void { + _messageSendHandler(e: MessageBoxMessageSendEvent): void { const { text, event } = e; const { user } = this.option(); @@ -98,27 +111,33 @@ class Chat extends Widget { text, }; - // @ts-expect-error - this.renderMessage(message, user); - // @ts-expect-error + this.renderMessage(message); this._messageSendAction?.({ message, event }); } + _focusTarget(): dxElementWrapper { + const $input = $(this.element()).find(`.${TEXTEDITOR_INPUT_CLASS}`); + + return $input; + } + _optionChanged(args: Record): void { const { name, value } = args; switch (name) { + case 'activeStateEnabled': + case 'focusStateEnabled': + case 'hoverStateEnabled': + this._messageBox.option({ [name]: value }); + break; case 'title': - // @ts-expect-error - this._chatHeader?.option(name, value); + this._chatHeader.option('title', (value as Properties['title']) ?? ''); break; case 'user': - // @ts-expect-error - this._messageList?.option('currentUserId', value.id); + this._messageList.option('currentUserId', (value as Properties['user'])?.id); break; case 'items': - // @ts-expect-error - this._messageList?.option(name, value); + this._messageList.option('items', (value as Properties['items']) ?? []); break; case 'onMessageSend': this._createMessageSendAction(); @@ -128,14 +147,12 @@ class Chat extends Widget { } } - renderMessage(message: Message, sender: User): void { + renderMessage(message: Message = {}): void { const { items } = this.option(); - const newItems = items ? [...items, message] : [message]; - - this._setOptionWithoutOptionChange('items', newItems); + const newItems = [...items ?? [], message]; - this._messageList?._renderMessage(message, newItems, sender); + this.option('items', newItems); } } diff --git a/packages/devextreme/js/__internal/ui/chat/chat_avatar.ts b/packages/devextreme/js/__internal/ui/chat/chat_avatar.ts index 67c335901e47..2c1d6903e19e 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat_avatar.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat_avatar.ts @@ -1,4 +1,6 @@ +import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import { isDefined } from '@js/core/utils/type'; import type { WidgetOptions } from '@js/ui/widget/ui.widget'; import Widget from '../widget'; @@ -11,6 +13,8 @@ export interface AvatarOptions extends WidgetOptions { } class Avatar extends Widget { + _$initials!: dxElementWrapper; + _getDefaultOptions(): AvatarOptions { return { ...super._getDefaultOptions(), @@ -18,28 +22,33 @@ class Avatar extends Widget { }; } - _getAvatarInitials(name: string): string { - const initials = name.charAt(0).toUpperCase(); - - return initials; - } - _initMarkup(): void { $(this.element()).addClass(CHAT_MESSAGE_AVATAR_CLASS); super._initMarkup(); - const $initials = $('
').addClass(CHAT_MESSAGE_AVATAR_INITIALS_CLASS); + this._renderInitialsElement(); + this._updateInitials(); + } + + _renderInitialsElement(): void { + this._$initials = $('
') + .addClass(CHAT_MESSAGE_AVATAR_INITIALS_CLASS) + .appendTo(this.element()); + } + _updateInitials(): void { const { name } = this.option(); - if (name) { - const text = this._getAvatarInitials(name); + this._$initials.text(this._getInitials(name)); + } - $initials.text(text); + _getInitials(name: string | undefined): string { + if (isDefined(name)) { + return String(name).charAt(0).toUpperCase(); } - $initials.appendTo(this.element()); + return ''; } _optionChanged(args: Record): void { @@ -47,6 +56,7 @@ class Avatar extends Widget { switch (name) { case 'name': + this._updateInitials(); break; default: super._optionChanged(args); diff --git a/packages/devextreme/js/__internal/ui/chat/chat_header.ts b/packages/devextreme/js/__internal/ui/chat/chat_header.ts index 427565f55d99..b2d2a804b63b 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat_header.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat_header.ts @@ -11,7 +11,7 @@ export interface ChatHeaderProperties extends Properties { } class ChatHeader extends DOMComponent { - private _$text?: dxElementWrapper; + private _$text!: dxElementWrapper; _getDefaultOptions(): ChatHeaderProperties { return { @@ -32,25 +32,28 @@ class ChatHeader extends DOMComponent { // @ts-expect-error super._initMarkup(); - this._renderText(); + this._renderTextElement(); + this._updateText(); } - _renderText(): void { - const { title } = this.option(); - + _renderTextElement(): void { this._$text = $('
') .addClass(CHAT_HEADER_TEXT_CLASS) - .text(title) .appendTo(this.element()); } + _updateText(): void { + const { title } = this.option(); + + this._$text.text(title); + } + _optionChanged(args: Record): void { - const { name, value } = args; + const { name } = args; switch (name) { case 'title': - // @ts-expect-error - this._$text?.text(value); + this._updateText(); break; default: // @ts-expect-error diff --git a/packages/devextreme/js/__internal/ui/chat/chat_message_box.ts b/packages/devextreme/js/__internal/ui/chat/chat_message_box.ts index 4311ab7b4bc6..c77948f7dc64 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat_message_box.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat_message_box.ts @@ -1,7 +1,8 @@ import $ from '@js/core/renderer'; +import type { NativeEventInfo } from '@js/events'; import type { ClickEvent } from '@js/ui/button'; import Button from '@js/ui/button'; -import type { MessageSendEvent } from '@js/ui/chat'; +import type { WidgetOptions } from '@js/ui/widget/ui.widget'; import type dxTextArea from '../../../ui/text_area'; import TextArea from '../m_text_area'; @@ -11,14 +12,18 @@ const CHAT_MESSAGE_BOX_CLASS = 'dx-chat-message-box'; const CHAT_MESSAGE_BOX_TEXTAREA_CLASS = 'dx-chat-message-box-text-area'; const CHAT_MESSAGE_BOX_BUTTON_CLASS = 'dx-chat-message-box-button'; -export interface MessageBoxProperties { +export type MessageSendEvent = + NativeEventInfo & + { text?: string }; + +export interface MessageBoxProperties extends WidgetOptions { onMessageSend?: (e: MessageSendEvent) => void; } class MessageBox extends Widget { - _textArea?: dxTextArea; + _textArea!: dxTextArea; - _button?: Button; + _button!: Button; _messageSendAction?: (e: Partial) => void; @@ -45,19 +50,38 @@ class MessageBox extends Widget { } _renderTextArea(): void { + const { + activeStateEnabled, + focusStateEnabled, + hoverStateEnabled, + } = this.option(); + const $textArea = $('
') .addClass(CHAT_MESSAGE_BOX_TEXTAREA_CLASS) .appendTo(this.element()); - this._textArea = this._createComponent($textArea, TextArea, {}); + this._textArea = this._createComponent($textArea, TextArea, { + activeStateEnabled, + focusStateEnabled, + hoverStateEnabled, + }); } _renderButton(): void { + const { + activeStateEnabled, + focusStateEnabled, + hoverStateEnabled, + } = this.option(); + const $button = $('
') .addClass(CHAT_MESSAGE_BOX_BUTTON_CLASS) .appendTo(this.element()); this._button = this._createComponent($button, Button, { + activeStateEnabled, + focusStateEnabled, + hoverStateEnabled, icon: 'send', stylingMode: 'text', onClick: (e): void => { @@ -74,9 +98,9 @@ class MessageBox extends Widget { } _sendHandler(e: ClickEvent): void { - const text = this._textArea?.option('text'); + const { text } = this._textArea.option(); - if (!text) { + if (!text?.trim()) { return; } @@ -85,9 +109,19 @@ class MessageBox extends Widget { } _optionChanged(args: Record): void { - const { name } = args; + const { name, value } = args; switch (name) { + case 'activeStateEnabled': + case 'focusStateEnabled': + case 'hoverStateEnabled': { + const options = { [name]: value }; + + this._button.option(options); + this._textArea.option(options); + + break; + } case 'onMessageSend': this._createMessageSendAction(); break; diff --git a/packages/devextreme/js/__internal/ui/chat/chat_message_bubble.ts b/packages/devextreme/js/__internal/ui/chat/chat_message_bubble.ts index 8ddbc18384c3..bde60390a260 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat_message_bubble.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat_message_bubble.ts @@ -18,15 +18,18 @@ class MessageBubble extends Widget { } _initMarkup(): void { - const $bubble = $(this.element()).addClass(CHAT_MESSAGE_BUBBLE_CLASS); + $(this.element()) + .addClass(CHAT_MESSAGE_BUBBLE_CLASS); - const { text } = this.option(); + super._initMarkup(); - if (text) { - $bubble.text(text); - } + this._updateText(); + } - super._initMarkup(); + _updateText(): void { + const { text = '' } = this.option(); + + $(this.element()).text(text); } _optionChanged(args: Record): void { @@ -34,6 +37,7 @@ class MessageBubble extends Widget { switch (name) { case 'text': + this._updateText(); break; default: super._optionChanged(args); diff --git a/packages/devextreme/js/__internal/ui/chat/chat_message_group.ts b/packages/devextreme/js/__internal/ui/chat/chat_message_group.ts index 14fa8ab15e8d..7b11a4ebbb44 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat_message_group.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat_message_group.ts @@ -1,5 +1,6 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import { isDefined } from '@js/core/utils/type'; import type { Message } from '@js/ui/chat'; import type { WidgetOptions } from '@js/ui/widget/ui.widget'; @@ -12,14 +13,16 @@ const CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS = 'dx-chat-message-group-alignmen const CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS = 'dx-chat-message-group-alignment-end'; const CHAT_MESSAGE_GROUP_INFORMATION_CLASS = 'dx-chat-message-group-information'; const CHAT_MESSAGE_TIME_CLASS = 'dx-chat-message-time'; -const CHAT_MESSAGE_NAME_CLASS = 'dx-chat-message-name'; +const CHAT_MESSAGE_AUTHOR_NAME_CLASS = 'dx-chat-message-author-name'; const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; const CHAT_MESSAGE_BUBBLE_FIRST_CLASS = 'dx-chat-message-bubble-first'; const CHAT_MESSAGE_BUBBLE_LAST_CLASS = 'dx-chat-message-bubble-last'; +export type MessageGroupAlignment = 'start' | 'end'; + export interface MessageGroupOptions extends WidgetOptions { items: Message[]; - alignment: 'start' | 'end'; + alignment: MessageGroupAlignment; } class MessageGroup extends Widget { @@ -33,41 +36,54 @@ class MessageGroup extends Widget { }; } - _getAlignmentClass(): string { + _updateAlignmentClass(): void { const { alignment } = this.option(); + $(this.element()) + .removeClass(CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS) + .removeClass(CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS); + const alignmentClass = alignment === 'start' ? CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS : CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS; - return alignmentClass; + $(this.element()) + .addClass(alignmentClass); } _initMarkup(): void { const { alignment, items } = this.option(); - const alignmentClass = this._getAlignmentClass(); - $(this.element()) - .addClass(CHAT_MESSAGE_GROUP_CLASS) - .addClass(alignmentClass); + .addClass(CHAT_MESSAGE_GROUP_CLASS); - super._initMarkup(); + this._updateAlignmentClass(); - if (alignment === 'start') { - const authorName = items[0].author?.name; + super._initMarkup(); - const $avatar = $('
').appendTo(this.element()); + if (items.length === 0) { + return; + } - this._avatar = this._createComponent($avatar, Avatar, { - name: authorName, - }); + if (alignment === 'start') { + this._renderAvatar(); } this._renderMessageGroupInformation(items?.[0]); this._renderMessageBubbles(items); } + _renderAvatar(): void { + const $avatar = $('
').appendTo(this.element()); + + const { items } = this.option(); + const authorName = items[0].author?.name; + + this._avatar = this._createComponent($avatar, Avatar, { + name: authorName, + }); + } + _renderMessageBubble(message: Message, index: number, length: number): void { const $bubble = $('
'); @@ -97,35 +113,38 @@ class MessageGroup extends Widget { _renderName(name: string, $element: dxElementWrapper): void { $('
') - .addClass(CHAT_MESSAGE_NAME_CLASS) + .addClass(CHAT_MESSAGE_AUTHOR_NAME_CLASS) .text(name) .appendTo($element); } - _renderTime(timestamp: string, $element: dxElementWrapper): void { + _getTimeValue(timestamp: string): string { const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', hour12: false }; const dateTime = new Date(Number(timestamp)); - const dateTimeString = dateTime.toLocaleTimeString(undefined, options); - $('
') - .addClass(CHAT_MESSAGE_TIME_CLASS) - .text(dateTimeString) - .appendTo($element); + return dateTime.toLocaleTimeString(undefined, options); } _renderMessageGroupInformation(message: Message): void { const { timestamp, author } = message; - const $messageGroupInformation = $('
').addClass(CHAT_MESSAGE_GROUP_INFORMATION_CLASS); - if (author?.name) { - this._renderName(author.name, $messageGroupInformation); - } + const $information = $('
') + .addClass(CHAT_MESSAGE_GROUP_INFORMATION_CLASS); + + $('
') + .addClass(CHAT_MESSAGE_AUTHOR_NAME_CLASS) + .text(author?.name ?? '') + .appendTo($information); + + const $time = $('
') + .addClass(CHAT_MESSAGE_TIME_CLASS) + .appendTo($information); - if (timestamp) { - this._renderTime(timestamp, $messageGroupInformation); + if (isDefined(timestamp)) { + $time.text(this._getTimeValue(timestamp)); } - $messageGroupInformation.appendTo(this.element()); + $information.appendTo(this.element()); } _updateLastBubbleClasses(): void { @@ -135,17 +154,6 @@ class MessageGroup extends Widget { $lastBubble.removeClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS); } - _renderMessage(message: Message): void { - const { items } = this.option(); - - const newItems = [...items, message]; - - this._setOptionWithoutOptionChange('items', newItems); - - this._updateLastBubbleClasses(); - this._renderMessageBubble(message, newItems.length - 1, newItems.length); - } - _optionChanged(args: Record): void { const { name } = args; @@ -158,6 +166,17 @@ class MessageGroup extends Widget { super._optionChanged(args); } } + + renderMessage(message: Message): void { + const { items } = this.option(); + + const newItems = [...items, message]; + + this._setOptionWithoutOptionChange('items', newItems); + + this._updateLastBubbleClasses(); + this._renderMessageBubble(message, newItems.length - 1, newItems.length); + } } export default MessageGroup; diff --git a/packages/devextreme/js/__internal/ui/chat/chat_message_list.ts b/packages/devextreme/js/__internal/ui/chat/chat_message_list.ts index 61a5752310fc..de538aab406f 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat_message_list.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat_message_list.ts @@ -2,34 +2,33 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { hasWindow } from '@js/core/utils/window'; -import type { Message, User } from '@js/ui/chat'; -import type dxScrollable from '@js/ui/scroll_view/ui.scrollable'; +import type { Message } from '@js/ui/chat'; import Scrollable from '@js/ui/scroll_view/ui.scrollable'; import type { WidgetOptions } from '@js/ui/widget/ui.widget'; import Widget from '../widget'; +import type { MessageGroupAlignment } from './chat_message_group'; import MessageGroup from './chat_message_group'; const CHAT_MESSAGE_LIST_CLASS = 'dx-chat-message-list'; -const CHAT_MESSAGE_LIST_CONTENT_CLASS = 'dx-chat-message-list-content'; export interface MessageListOptions extends WidgetOptions { - items?: Message[]; - currentUserId?: number | string; + items: Message[]; + currentUserId: number | string | undefined; } class MessageList extends Widget { _messageGroups?: MessageGroup[]; - private _$content?: dxElementWrapper; + private _$content!: dxElementWrapper; - private _scrollable?: dxScrollable; + private _scrollable!: Scrollable; _getDefaultOptions(): MessageListOptions { return { ...super._getDefaultOptions(), items: [], - currentUserId: undefined, + currentUserId: '', }; } @@ -44,50 +43,44 @@ class MessageList extends Widget { super._initMarkup(); - this._renderScrollable(); this._renderMessageListContent(); + this._renderScrollable(); + this._scrollContentToLastMessageGroup(); } - _isCurrentUser(id): boolean { + _isCurrentUser(id: string | number | undefined): boolean { const { currentUserId } = this.option(); return currentUserId === id; } - _messageGroupAlignment(id): 'start' | 'end' { + _messageGroupAlignment(id: string | number | undefined): MessageGroupAlignment { return this._isCurrentUser(id) ? 'end' : 'start'; } - _createMessageGroupComponent(items, userId): void { - if (!this._$content) { - return; - } - + _createMessageGroupComponent(items: Message[], userId: string | number | undefined): void { const $messageGroup = $('
').appendTo(this._$content); - const options = { + const messageGroup = this._createComponent($messageGroup, MessageGroup, { items, alignment: this._messageGroupAlignment(userId), - }; - - const messageGroup = this._createComponent($messageGroup, MessageGroup, options); + }); this._messageGroups?.push(messageGroup); } _renderScrollable(): void { - this._scrollable = this._createComponent('
', Scrollable, { useNative: true }); - this.$element().append(this._scrollable.$element()); + this._scrollable = this._createComponent(this._$content, Scrollable, { + useNative: true, + }); } _renderMessageListContent(): void { const { items } = this.option(); this._$content = $('
') - .addClass(CHAT_MESSAGE_LIST_CONTENT_CLASS) - // @ts-expect-error - .appendTo(this._scrollable?.$content()); + .appendTo(this.$element()); if (!items?.length) { return; @@ -97,16 +90,18 @@ class MessageList extends Widget { let currentMessageGroupItems: Message[] = []; items.forEach((item, index) => { - const id = item?.author?.id; + const newMessageGroupItem = item ?? {}; + + const id = newMessageGroupItem.author?.id; if (id === currentMessageGroupUserId) { - currentMessageGroupItems.push(item); + currentMessageGroupItems.push(newMessageGroupItem); } else { this._createMessageGroupComponent(currentMessageGroupItems, currentMessageGroupUserId); currentMessageGroupUserId = id; currentMessageGroupItems = []; - currentMessageGroupItems.push(item); + currentMessageGroupItems.push(newMessageGroupItem); } if (items.length - 1 === index) { @@ -115,7 +110,9 @@ class MessageList extends Widget { }); } - _renderMessage(message: Message, newItems: Message[], sender: User): void { + _renderMessage(message: Message, newItems: Message[]): void { + const sender = message.author; + this._setOptionWithoutOptionChange('items', newItems); const lastMessageGroup = this._messageGroups?.[this._messageGroups.length - 1]; @@ -123,8 +120,8 @@ class MessageList extends Widget { if (lastMessageGroup) { const lastMessageGroupUserId = lastMessageGroup.option('items')[0].author?.id; - if (sender.id === lastMessageGroupUserId) { - lastMessageGroup._renderMessage(message); + if (sender?.id === lastMessageGroupUserId) { + lastMessageGroup.renderMessage(message); this._scrollContentToLastMessageGroup(); @@ -132,12 +129,12 @@ class MessageList extends Widget { } } - this._createMessageGroupComponent([message], sender.id); + this._createMessageGroupComponent([message], sender?.id); this._scrollContentToLastMessageGroup(); } _scrollContentToLastMessageGroup(): void { - if (!(this._messageGroups?.length && this._scrollable && hasWindow())) { + if (!(this._messageGroups?.length && hasWindow())) { return; } @@ -153,14 +150,49 @@ class MessageList extends Widget { super._clean(); } + _isMessageAddedToEnd(value: Message[], previousValue: Message[]): boolean { + const valueLength = value.length; + const previousValueLength = previousValue.length; + + if (valueLength === 0) { + return false; + } + + if (previousValueLength === 0) { + return valueLength === 1; + } + + const lastValueItem = value[valueLength - 1]; + const lastPreviousValueItem = previousValue[previousValueLength - 1]; + + const isLastItemNotTheSame = lastValueItem !== lastPreviousValueItem; + const isLengthIncreasedByOne = valueLength - previousValueLength === 1; + + return isLastItemNotTheSame && isLengthIncreasedByOne; + } + + _processItemsUpdating(value: Message[], previousValue: Message[]): void { + const shouldItemsBeUpdatedCompletely = !this._isMessageAddedToEnd(value, previousValue); + + if (shouldItemsBeUpdatedCompletely) { + this._invalidate(); + } else { + const newMessage = value[value.length - 1]; + + this._renderMessage(newMessage ?? {}, value); + } + } + _optionChanged(args: Record): void { - const { name } = args; + const { name, value, previousValue } = args; switch (name) { - case 'items': case 'currentUserId': this._invalidate(); break; + case 'items': + this._processItemsUpdating(value as MessageListOptions['items'], previousValue as MessageListOptions['items']); + break; default: super._optionChanged(args); } diff --git a/packages/devextreme/js/__internal/ui/collection/m_collection_widget.base.ts b/packages/devextreme/js/__internal/ui/collection/m_collection_widget.base.ts index 0f34ab544d44..9123c3bc0dc5 100644 --- a/packages/devextreme/js/__internal/ui/collection/m_collection_widget.base.ts +++ b/packages/devextreme/js/__internal/ui/collection/m_collection_widget.base.ts @@ -252,7 +252,10 @@ const CollectionWidget = Widget.inherit({ const $focusedElement = $(this.option('focusedElement')); if ($focusedElement.length) { + // NOTE: If focusedElement is set, selection was already processed on its focusing. + this._shouldSkipSelectOnFocus = true; this._setFocusedItem($focusedElement); + this._shouldSkipSelectOnFocus = false; } else { const $activeItem = this._getActiveItem(); if ($activeItem.length) { @@ -406,7 +409,7 @@ const CollectionWidget = Widget.inherit({ const { selectOnFocus } = this.option(); const isTargetDisabled = this._isDisabled($target); - if (selectOnFocus && !isTargetDisabled) { + if (selectOnFocus && !isTargetDisabled && !this._shouldSkipSelectOnFocus) { this._selectFocusedItem($target); } }, @@ -725,7 +728,10 @@ const CollectionWidget = Widget.inherit({ const $closestFocusable = this._closestFocusable($target); if ($closestItem.length && this._isFocusTarget($closestFocusable?.get(0))) { + // NOTE: Selection here is already processed in click handler. + this._shouldSkipSelectOnFocus = true; this.option('focusedElement', getPublicElement($closestItem)); + this._shouldSkipSelectOnFocus = false; } }.bind(this); diff --git a/packages/devextreme/js/__internal/ui/collection/m_collection_widget.edit.ts b/packages/devextreme/js/__internal/ui/collection/m_collection_widget.edit.ts index d242dac656cb..f92be9eff98e 100644 --- a/packages/devextreme/js/__internal/ui/collection/m_collection_widget.edit.ts +++ b/packages/devextreme/js/__internal/ui/collection/m_collection_widget.edit.ts @@ -9,7 +9,7 @@ import { } from '@js/core/utils/deferred'; import { extend } from '@js/core/utils/extend'; import { each } from '@js/core/utils/iterator'; -import { isDefined, isPromise } from '@js/core/utils/type'; +import { isDefined } from '@js/core/utils/type'; import { DataSource } from '@js/data/data_source/data_source'; import { normalizeLoadResult } from '@js/data/data_source/utils'; import eventsEngine from '@js/events/core/events_engine'; @@ -150,9 +150,24 @@ const CollectionWidget = BaseCollectionWidget.inherit({ mode: this.option('selectionMode'), maxFilterLengthInRequest: this.option('maxFilterLengthInRequest'), equalByReference: !this._isKeySpecified(), - onSelectionChanged(args) { + onSelectionChanging: (args): void => { + const isSelectionChanged = args.addedItemKeys.length || args.removedItemKeys.length; + if (!this._rendered || !isSelectionChanged) { + return; + } + + const selectionChangingArgs = { + removedItems: args.removedItems, + addedItems: args.addedItems, + cancel: false, + }; + this._actions.onSelectionChanging?.(selectionChangingArgs); + args.cancel = selectionChangingArgs.cancel; + }, + onSelectionChanged: (args): void => { if (args.addedItemKeys.length || args.removedItemKeys.length) { - that._processSelectionChanging(args); + this.option('selectedItems', this._getItemsByKeys(args.selectedItemKeys, args.selectedItems)); + this._updateSelectedItems(args); } }, filter: that._getCombinedFilter.bind(that), @@ -408,10 +423,10 @@ const CollectionWidget = BaseCollectionWidget.inherit({ }); }, - _itemSelectHandler(e) { + _itemSelectHandler(e, shouldIgnoreSelectByClick) { let itemSelectPromise; - if (!this.option('selectByClick')) { + if (!shouldIgnoreSelectByClick && !this.option('selectByClick')) { return; } @@ -445,39 +460,6 @@ const CollectionWidget = BaseCollectionWidget.inherit({ this._setAriaSelectionAttribute($itemElement, String(isSelected)); }, - _processSelectionChanging(args) { - const updateSelectedItemsIfNeeded = (args, cancel: boolean): void => { - if (!cancel) { - this.option('selectedItems', this._getItemsByKeys(args.selectedItemKeys, args.selectedItems)); - this._updateSelectedItems(args); - } - }; - - if (!this._rendered) { - updateSelectedItemsIfNeeded(args, false); - return; - } - - const selectionChangingArgs = { - removedItems: args.removedItems, - addedItems: args.addedItems, - cancel: false, - }; - this._actions.onSelectionChanging(selectionChangingArgs); - - if (isPromise(selectionChangingArgs.cancel)) { - selectionChangingArgs.cancel - .then((cancel) => { - updateSelectedItemsIfNeeded(args, cancel); - }) - .catch(() => { - updateSelectedItemsIfNeeded(args, false); - }); - } else { - updateSelectedItemsIfNeeded(args, selectionChangingArgs.cancel); - } - }, - _updateSelectedItems(args) { const that = this; const { addedItemKeys } = args; @@ -708,17 +690,17 @@ const CollectionWidget = BaseCollectionWidget.inherit({ }, selectItem(itemElement) { - if (this.option('selectionMode') === 'none') return; + if (this.option('selectionMode') === 'none') return Deferred().resolve(); const itemIndex = this._editStrategy.getNormalizedIndex(itemElement); if (!indexExists(itemIndex)) { - return; + return Deferred().resolve(); } const key = this._getKeyByIndex(itemIndex); if (this._selection.isItemSelected(key)) { - return; + return Deferred().resolve(); } if (this.option('selectionMode') === 'single') { diff --git a/packages/devextreme/js/__internal/ui/list/m_list.base.ts b/packages/devextreme/js/__internal/ui/list/m_list.base.ts index f36c59ba74d6..4f39d2db85ac 100644 --- a/packages/devextreme/js/__internal/ui/list/m_list.base.ts +++ b/packages/devextreme/js/__internal/ui/list/m_list.base.ts @@ -44,6 +44,8 @@ const LIST_GROUP_COLLAPSED_CLASS = 'dx-list-group-collapsed'; const LIST_GROUP_HEADER_INDICATOR_CLASS = 'dx-list-group-header-indicator'; const LIST_HAS_NEXT_CLASS = 'dx-has-next'; const LIST_NEXT_BUTTON_CLASS = 'dx-list-next-button'; +const LIST_SELECT_CHECKBOX = 'dx-list-select-checkbox'; +const LIST_SELECT_RADIOBUTTON = 'dx-list-select-radiobutton'; const WRAP_ITEM_TEXT_CLASS = 'dx-wrap-item-text'; const SELECT_ALL_ITEM_SELECTOR = '.dx-list-select-all'; @@ -323,11 +325,20 @@ export const ListBase = CollectionWidget.inherit({ }, _itemSelectHandler(e) { - if (this.option('selectionMode') === 'single' && this.isItemSelected(e.currentTarget)) { + const isSingleSelectedItemClicked = this.option('selectionMode') === 'single' + && this.isItemSelected(e.currentTarget); + if (isSingleSelectedItemClicked) { return; } - return this.callBase(e); + const isSelectionControlClicked = $(e.target).closest(`.${LIST_SELECT_CHECKBOX}`).length + || $(e.target).closest(`.${LIST_SELECT_RADIOBUTTON}`).length; + + if (isSelectionControlClicked) { + this.option('focusedElement', e.currentTarget); + } + + return this.callBase(e, isSelectionControlClicked); }, _allowDynamicItemsAppend() { diff --git a/packages/devextreme/js/__internal/ui/list/m_list.edit.decorator.selection.ts b/packages/devextreme/js/__internal/ui/list/m_list.edit.decorator.selection.ts index cc5a44de30fb..da3e08879e41 100644 --- a/packages/devextreme/js/__internal/ui/list/m_list.edit.decorator.selection.ts +++ b/packages/devextreme/js/__internal/ui/list/m_list.edit.decorator.selection.ts @@ -1,4 +1,5 @@ import $ from '@js/core/renderer'; +import { Deferred, type DeferredObj } from '@js/core/utils/deferred'; import { extend } from '@js/core/utils/extend'; import { name as clickEventName } from '@js/events/click'; import eventsEngine from '@js/events/core/events_engine'; @@ -59,11 +60,13 @@ registerDecorator( elementAttr: { 'aria-label': 'Check State' }, focusStateEnabled: false, hoverStateEnabled: false, - onValueChanged: function (e) { - e.event && this._list._saveSelectionChangeEvent(e.event); - this._processCheckedState($itemElement, e.value); - e.event && e.event.stopPropagation(); - }.bind(this), + onValueChanged: ({ value, component, event }) => { + const isUiClick = !!event; + if (isUiClick) { + component._valueChangeEventInstance = undefined; + component.option('value', !value); + } + }, })); }, @@ -120,8 +123,7 @@ registerDecorator( handleEnterPressing(e) { if (this._$selectAll && this._$selectAll.hasClass(FOCUSED_STATE_CLASS)) { e.target = this._$selectAll.get(0); - this._list._saveSelectionChangeEvent(e); - this._selectAllCheckBox.option('value', !this._selectAllCheckBox.option('value')); + this._selectAllHandler(e); return true; } }, @@ -159,10 +161,19 @@ registerDecorator( }, _attachSelectAllHandler() { - this._selectAllCheckBox.option('onValueChanged', this._selectAllHandler.bind(this)); + this._selectAllCheckBox.option('onValueChanged', ({ value, event, component }) => { + const isUiClick = !!event; + if (isUiClick) { + component._setOptionWithoutOptionChange('value', !value); + return; + } + + this._updateSelectAllAriaLabel(); + this._list._createActionByOption('onSelectAllValueChanged')({ value }); + }); eventsEngine.off(this._$selectAll, CLICK_EVENT_NAME); - eventsEngine.on(this._$selectAll, CLICK_EVENT_NAME, this._selectAllClickHandler.bind(this)); + eventsEngine.on(this._$selectAll, CLICK_EVENT_NAME, this._selectAllHandler.bind(this)); }, _updateSelectAllAriaLabel() { @@ -180,21 +191,22 @@ registerDecorator( this._$selectAll.attr({ 'aria-label': label }); }, - _selectAllHandler(e) { - e.event && e.event.stopPropagation(); - e.event && this._list._saveSelectionChangeEvent(e.event); + _selectAllHandler(event): DeferredObj { + event.stopPropagation(); + this._list._saveSelectionChangeEvent(event); const { value } = this._selectAllCheckBox.option(); - if (value) { - this._selectAllItems(); - } else if (value === false) { - this._unselectAllItems(); + let selectionDeferred; + if (value !== true) { + selectionDeferred = this._selectAllItems(); + } else { + selectionDeferred = this._unselectAllItems(); } - this._updateSelectAllAriaLabel(); + this._list.option('focusedElement', this._$selectAll.get(0)); - this._list._createActionByOption('onSelectAllValueChanged')({ value }); + return selectionDeferred; }, _checkSelectAllCapability() { @@ -209,34 +221,21 @@ registerDecorator( }, _selectAllItems() { - if (!this._checkSelectAllCapability()) return; + if (!this._checkSelectAllCapability()) return Deferred().resolve(); - this._list._selection.selectAll(this._list.option('selectAllMode') === 'page'); + return this._list._selection.selectAll(this._list.option('selectAllMode') === 'page'); }, _unselectAllItems() { - if (!this._checkSelectAllCapability()) return; - - this._list._selection.deselectAll(this._list.option('selectAllMode') === 'page'); - }, + if (!this._checkSelectAllCapability()) return Deferred().resolve(); - _selectAllClickHandler(e) { - this._list._saveSelectionChangeEvent(e); - this._selectAllCheckBox.option('value', !this._selectAllCheckBox.option('value')); + return this._list._selection.deselectAll(this._list.option('selectAllMode') === 'page'); }, _isSelected($itemElement) { return this._list.isItemSelected($itemElement); }, - _processCheckedState($itemElement, checked) { - if (checked) { - this._list.selectItem($itemElement); - } else { - this._list.unselectItem($itemElement); - } - }, - dispose() { this._disposeSelectAll(); this._list.$element().removeClass(SELECT_DECORATOR_ENABLED_CLASS); diff --git a/packages/devextreme/js/__internal/ui/m_multi_view.ts b/packages/devextreme/js/__internal/ui/m_multi_view.ts index 0527cfd09e0f..3d7abf6ff7a0 100644 --- a/packages/devextreme/js/__internal/ui/m_multi_view.ts +++ b/packages/devextreme/js/__internal/ui/m_multi_view.ts @@ -428,13 +428,21 @@ const MultiView = CollectionWidget.inherit({ return index; }, + _postprocessSwipe() {}, + _swipeEndHandler(e) { const targetOffset = e.targetOffset * this._getRTLSignCorrection(); if (targetOffset) { const newSelectedIndex = this._findNextAvailableIndex(this.option('selectedIndex'), -targetOffset); - - this.option('selectedIndex', newSelectedIndex); + this + .selectItem(newSelectedIndex) + .fail(() => { + this._animateItemContainer(0, noop); + }) + .done(() => { + this._postprocessSwipe({ swipedTabsIndex: newSelectedIndex }); + }); // TODO: change focusedElement on focusedItem const $selectedElement = this.itemElements().filter('.dx-item-selected'); diff --git a/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.deferred.ts b/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.deferred.ts index 13cebb543fad..617e73cba900 100644 --- a/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.deferred.ts +++ b/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.deferred.ts @@ -1,3 +1,4 @@ +import type { DeferredObj } from '@js/core/utils/deferred'; import { Deferred } from '@js/core/utils/deferred'; import { isString } from '@js/core/utils/type'; import dataQuery from '@js/data/query'; @@ -68,13 +69,20 @@ export default class DeferredStrategy extends SelectionStrategy { } isItemKeySelected(itemData) { - const { selectionFilter } = this.options; + const { selectionFilter, sensitivity } = this.options; if (!selectionFilter) { return true; } - return !!dataQuery([itemData]).filter(selectionFilter).toArray().length; + const queryParams = { + langParams: { + collatorOptions: { + sensitivity, + }, + }, + }; + return !!dataQuery([itemData], queryParams).filter(selectionFilter).toArray().length; } _getKeyExpr() { @@ -343,4 +351,12 @@ export default class DeferredStrategy extends SelectionStrategy { return this._loadFilteredData(filter); } + + _onePageSelectAll(isDeselect: boolean): DeferredObj { + this._selectAllPlainItems(isDeselect); + + this.onSelectionChanged(); + + return Deferred().resolve(); + } } diff --git a/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.standard.ts b/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.standard.ts index 5bca3a34ee6e..98dd33692ecc 100644 --- a/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.standard.ts +++ b/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.standard.ts @@ -2,6 +2,7 @@ import { getUniqueValues, removeDuplicates } from '@js/core/utils/array'; import { isKeysEqual } from '@js/core/utils/array_compare'; // @ts-expect-error import { getKeyHash } from '@js/core/utils/common'; +import type { DeferredObj } from '@js/core/utils/deferred'; import { Deferred, when } from '@js/core/utils/deferred'; import { SelectionFilterCreator } from '@js/core/utils/selection_filter'; import { isDefined, isObject } from '@js/core/utils/type'; @@ -17,6 +18,16 @@ export default class StandardStrategy extends SelectionStrategy { _lastRequestData?: any; + _isCancelingInProgress?: boolean; + + _lastSelectAllPageDeferred = Deferred().reject(); + + _storedSelectionState?: { + selectedItems: any; + selectedItemKeys: any; + keyHashIndices: any; + }; + constructor(options) { super(options); this._initSelectedItemKeyHash(); @@ -245,24 +256,47 @@ export default class StandardStrategy extends SelectionStrategy { } selectedItemKeys(keys, preserve, isDeselect, isSelectAll, updatedKeys, forceCombinedFilter = false) { - const that = this; - const deferred = that._loadSelectedItems(keys, isDeselect, isSelectAll, updatedKeys, forceCombinedFilter); + if (this._isCancelingInProgress) { + return Deferred().reject(); + } + + const loadingDeferred = this._loadSelectedItems( + keys, + isDeselect, + isSelectAll, + updatedKeys, + forceCombinedFilter, + ); + + const selectionDeferred = Deferred(); + + loadingDeferred.done((items) => { + this._storeSelectionState(); - deferred.done((items) => { if (preserve) { - that._preserveSelectionUpdate(items, isDeselect); + this._preserveSelectionUpdate(items, isDeselect); } else { - that._replaceSelectionUpdate(items); + this._replaceSelectionUpdate(items); } /// #DEBUG if (!isSelectAll && !isDeselect) { - that._warnOnIncorrectKeys(keys); + this._warnOnIncorrectKeys(keys); } /// #ENDDEBUG - that.onSelectionChanged(); + + this._isCancelingInProgress = true; + this._callCallbackIfNotCanceled(() => { + this._isCancelingInProgress = false; + this.onSelectionChanged(); + selectionDeferred.resolve(items); + }, () => { + this._isCancelingInProgress = false; + this._restoreSelectionState(); + selectionDeferred.reject(); + }); }); - return deferred; + return selectionDeferred; } addSelectedItem(key, itemData) { @@ -473,4 +507,45 @@ export default class StandardStrategy extends SelectionStrategy { return this._loadFilteredData(combinedFilter); } + + _storeSelectionState(): void { + const { selectedItems, selectedItemKeys, keyHashIndices } = this.options; + + this._storedSelectionState = { + keyHashIndices: { ...keyHashIndices }, + selectedItems: [...selectedItems], + selectedItemKeys: [...selectedItemKeys], + }; + } + + _restoreSelectionState(): void { + this._clearItemKeys(); + + const { selectedItemKeys, selectedItems, keyHashIndices } = this._storedSelectionState!; + this._setOption('selectedItemKeys', selectedItemKeys); + this._setOption('selectedItems', selectedItems); + this._setOption('keyHashIndices', keyHashIndices); + } + + _onePageSelectAll(isDeselect: boolean): DeferredObj { + if (this._lastSelectAllPageDeferred.state() === 'pending') { + return Deferred().reject(); + } + + this._storeSelectionState(); + + this._selectAllPlainItems(isDeselect); + + this._lastSelectAllPageDeferred = Deferred(); + + this._callCallbackIfNotCanceled(() => { + this.onSelectionChanged(); + this._lastSelectAllPageDeferred.resolve(); + }, () => { + this._restoreSelectionState(); + this._lastSelectAllPageDeferred.reject(); + }); + + return this._lastSelectAllPageDeferred; + } } diff --git a/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.ts b/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.ts index 75c9014def47..6bb5250df03d 100644 --- a/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.ts +++ b/packages/devextreme/js/__internal/ui/selection/m_selection.strategy.ts @@ -5,12 +5,14 @@ import { noop, } from '@js/core/utils/common'; import { Deferred } from '@js/core/utils/deferred'; -import { isObject, isPlainObject } from '@js/core/utils/type'; +import { isObject, isPlainObject, isPromise } from '@js/core/utils/type'; import dataQuery from '@js/data/query'; export default class SelectionStrategy { options: any; + _lastSelectAllPageDeferred = Deferred().reject(); + constructor(options) { this.options = options; @@ -33,6 +35,53 @@ export default class SelectionStrategy { this.options[name] = value; } + onSelectionChanging(): boolean | Promise { + const { + selectedItems, + selectedItemKeys, + addedItemKeys, + removedItemKeys, + addedItems, + removedItems, + onSelectionChanging = noop, + } = this.options; + + const selectionChangingArgs = { + selectedItems, + selectedItemKeys, + addedItemKeys, + removedItemKeys, + addedItems, + removedItems, + cancel: false, + }; + + onSelectionChanging(selectionChangingArgs); + return selectionChangingArgs.cancel; + } + + _callCallbackIfNotCanceled(callback: () => void, cancelCallback: () => void): void { + const cancelResult = this.onSelectionChanging(); + + if (isPromise(cancelResult)) { + cancelResult + .then((cancel) => { + if (!cancel) { + callback(); + } else { + cancelCallback(); + } + }) + .catch(() => { + callback(); + }); + } else if (!cancelResult) { + callback(); + } else { + cancelCallback(); + } + } + onSelectionChanged() { const { selectedItems, @@ -175,7 +224,6 @@ export default class SelectionStrategy { const key = this.options.keyOf(itemData); if (this.options.isSelectableItem(item)) { - // @ts-expect-error if (this.isItemKeySelected(key)) { hasSelectedItems = true; } else { @@ -189,4 +237,40 @@ export default class SelectionStrategy { } return false; } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isItemKeySelected(itemKey): boolean { + throw new Error('isItemKeySelected method should be overriden'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addSelectedItem(itemKey, itemData): void { + throw new Error('addSelectedItem method should be overriden'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + removeSelectedItem(itemKey): void { + throw new Error('removeSelectedItem method should be overriden'); + } + + _selectAllPlainItems(isDeselect: boolean): void { + const items = this.getSelectableItems(this.options.plainItems()); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + if (this.options.isSelectableItem(item)) { + const itemData = this.options.getItemData(item); + const itemKey = this.options.keyOf(itemData); + const isSelected = this.isItemKeySelected(itemKey); + + if (!isSelected && !isDeselect) { + this.addSelectedItem(itemKey, itemData); + } + + if (isSelected && isDeselect) { + this.removeSelectedItem(itemKey); + } + } + } + } } diff --git a/packages/devextreme/js/__internal/ui/selection/m_selection.ts b/packages/devextreme/js/__internal/ui/selection/m_selection.ts index 763271fc10c7..e8fedc7281d8 100644 --- a/packages/devextreme/js/__internal/ui/selection/m_selection.ts +++ b/packages/devextreme/js/__internal/ui/selection/m_selection.ts @@ -314,7 +314,7 @@ export default class Selection { this._resetFocusedItemIndex(); if (isOnePage) { - return this._onePageSelectAll(false); + return this._selectionStrategy._onePageSelectAll(false); } return this.selectedItemKeys([], true, false, true); } @@ -323,36 +323,11 @@ export default class Selection { this._resetFocusedItemIndex(); if (isOnePage) { - return this._onePageSelectAll(true); + return this._selectionStrategy._onePageSelectAll(true); } return this.selectedItemKeys([], true, true, true); } - _onePageSelectAll(isDeselect) { - const items = this._selectionStrategy.getSelectableItems(this.options.plainItems()); - for (let i = 0; i < items.length; i++) { - const item = items[i]; - - if (this.isDataItem(item)) { - const itemData = this.options.getItemData(item); - const itemKey = this.options.keyOf(itemData); - const isSelected = this.isItemSelected(itemKey); - - if (!isSelected && !isDeselect) { - this._addSelectedItem(itemData, itemKey); - } - - if (isSelected && isDeselect) { - this._removeSelectedItem(itemKey); - } - } - } - - this.onSelectionChanged(); - - return Deferred().resolve(); - } - getSelectAllState(visibleOnly) { return this._selectionStrategy.getSelectAllState(visibleOnly); } diff --git a/packages/devextreme/js/__internal/ui/tab_panel/m_tab_panel.ts b/packages/devextreme/js/__internal/ui/tab_panel/m_tab_panel.ts index 0f3d5637dee8..2fecc3b5a650 100644 --- a/packages/devextreme/js/__internal/ui/tab_panel/m_tab_panel.ts +++ b/packages/devextreme/js/__internal/ui/tab_panel/m_tab_panel.ts @@ -69,6 +69,7 @@ const TabPanel = MultiView.inherit({ return extend(this.callBase(), { itemTitleTemplate: 'title', hoverStateEnabled: true, + selectOnFocus: false, showNavButtons: false, scrollByContent: true, scrollingEnabled: true, @@ -262,10 +263,32 @@ const TabPanel = MultiView.inherit({ onItemClick: this._titleClickAction.bind(this), onItemHold: this._titleHoldAction.bind(this), itemHoldTimeout: this.option('itemHoldTimeout'), - onSelectionChanged: function (e) { - this.option('selectedIndex', e.component.option('selectedIndex')); + onSelectionChanging: (e): void => { + const newTabsSelectedItemData = e.addedItems[0]; + const newTabsSelectedIndex = this._getIndexByItemData(newTabsSelectedItemData); + + const selectingResult = this.selectItem(newTabsSelectedIndex); + + const promiseState = selectingResult.state(); + if (promiseState !== 'pending') { + // NOTE: Keep selection change process synchronious if possible. + e.cancel = promiseState === 'rejected'; + return; + } + + e.cancel = new Promise((resolve) => { + selectingResult + .done(() => { + resolve(false); + }) + .fail(() => { + resolve(true); + }); + }); + }, + onSelectionChanged: (): void => { this._refreshActiveDescendant(); - }.bind(this), + }, onItemRendered: this._titleRenderedAction.bind(this), itemTemplate: this._getTemplateByOption('itemTitleTemplate'), items: this.option('items'), @@ -393,6 +416,10 @@ const TabPanel = MultiView.inherit({ } }, + _postprocessSwipe({ swipedTabsIndex }): void { + this._setTabsOption('selectedIndex', swipedTabsIndex); + }, + _visibilityChanged(visible) { if (visible) { this._tabs._dimensionChanged(); diff --git a/packages/devextreme/js/core/class.js b/packages/devextreme/js/core/class.js index cedc3fa81cf1..ec74b4165604 100644 --- a/packages/devextreme/js/core/class.js +++ b/packages/devextreme/js/core/class.js @@ -104,20 +104,6 @@ const abstract = function() { throw errors.Error('E0001'); }; -const copyStatic = (function() { - const hasOwn = Object.prototype.hasOwnProperty; - - return function(source, destination) { - for(const key in source) { - if(!hasOwn.call(source, key)) { - return; - } - - destination[key] = source[key]; - } - }; -})(); - const classImpl = function() { }; classImpl.inherit = function(members) { @@ -147,7 +133,7 @@ classImpl.inherit = function(members) { inheritor.prototype = clonePrototype(this); - copyStatic(this, inheritor); + Object.setPrototypeOf(inheritor, this); inheritor.inherit = this.inherit; inheritor.abstract = abstract; diff --git a/packages/devextreme/js/core/component.js b/packages/devextreme/js/core/component.js index e1d3a6257f8f..134da8a3333e 100644 --- a/packages/devextreme/js/core/component.js +++ b/packages/devextreme/js/core/component.js @@ -1,388 +1,8 @@ -import Config from './config'; -import { extend } from './utils/extend'; -import { Options } from './options/index'; -import { convertRulesToOptions } from './options/utils'; -import Class from './class'; -import Action from './action'; -import errors from './errors'; -import Callbacks from './utils/callbacks'; -import { EventsStrategy } from './events_strategy'; -import { name as publicComponentName } from './utils/public_component'; -import { PostponedOperations } from './postponed_operations'; -import { isFunction, isPlainObject, isDefined } from './utils/type'; -import { noop } from './utils/common'; -import { getPathParts } from './utils/data'; - -const getEventName = (actionName) => { - return actionName.charAt(2).toLowerCase() + actionName.substr(3); -}; - -const isInnerOption = (optionName) => { - return optionName.indexOf('_', 0) === 0; -}; - -export const Component = Class.inherit({ - _setDeprecatedOptions() { - this._deprecatedOptions = {}; - }, - - _getDeprecatedOptions() { - return this._deprecatedOptions; - }, - - _getDefaultOptions() { - return { - onInitialized: null, - onOptionChanged: null, - onDisposing: null, - - defaultOptionsRules: null - }; - }, - - _defaultOptionsRules() { - return []; - }, - - _setOptionsByDevice(rules) { - this._options.applyRules(rules); - }, - - _convertRulesToOptions(rules) { - return convertRulesToOptions(rules); - }, - - _isInitialOptionValue(name) { - return this._options.isInitial(name); - }, - - _setOptionsByReference() { - this._optionsByReference = {}; - }, - - _getOptionsByReference() { - return this._optionsByReference; - }, - /** - * @name Component.ctor - * @publicName ctor(options) - * @param1 options:ComponentOptions|undefined - * @hidden - */ - ctor(options = {}) { - const { _optionChangedCallbacks, _disposingCallbacks } = options; - - this.NAME = publicComponentName(this.constructor); - - this._eventsStrategy = EventsStrategy.create(this, options.eventsStrategy); - - this._updateLockCount = 0; - - this._optionChangedCallbacks = _optionChangedCallbacks || Callbacks(); - this._disposingCallbacks = _disposingCallbacks || Callbacks(); - this.postponedOperations = new PostponedOperations(); - this._createOptions(options); - }, - - _createOptions(options) { - this.beginUpdate(); - - try { - this._setOptionsByReference(); - this._setDeprecatedOptions(); - this._options = new Options( - this._getDefaultOptions(), - this._getDefaultOptions(), - this._getOptionsByReference(), - this._getDeprecatedOptions() - ); - - this._options.onChanging( - (name, previousValue, value) => this._initialized && this._optionChanging(name, previousValue, value)); - this._options.onDeprecated( - (option, info) => this._logDeprecatedOptionWarning(option, info)); - this._options.onChanged( - (name, value, previousValue) => this._notifyOptionChanged(name, value, previousValue)); - this._options.onStartChange(() => this.beginUpdate()); - this._options.onEndChange(() => this.endUpdate()); - this._options.addRules(this._defaultOptionsRules()); - - if(options && options.onInitializing) { - options.onInitializing.apply(this, [options]); - } - - this._setOptionsByDevice(options.defaultOptionsRules); - this._initOptions(options); - } finally { - this.endUpdate(); - } - }, - - _initOptions(options) { - this.option(options); - }, - - _init() { - this._createOptionChangedAction(); - - this.on('disposing', (args) => { - this._disposingCallbacks.fireWith(this, [args]); - }); - }, - - _logDeprecatedOptionWarning(option, info) { - const message = info.message || (`Use the '${info.alias}' option instead`); - errors.log('W0001', this.NAME, option, info.since, message); - }, - - _logDeprecatedComponentWarning(since, alias) { - errors.log('W0000', this.NAME, since, `Use the '${alias}' widget instead`); - }, - - _createOptionChangedAction() { - this._optionChangedAction = this._createActionByOption('onOptionChanged', { excludeValidators: ['disabled', 'readOnly'] }); - }, - - _createDisposingAction() { - this._disposingAction = this._createActionByOption('onDisposing', { excludeValidators: ['disabled', 'readOnly'] }); - }, - - _optionChanged(args) { - switch(args.name) { - case 'onDisposing': - case 'onInitialized': - break; - case 'onOptionChanged': - this._createOptionChangedAction(); - break; - case 'defaultOptionsRules': - break; - } - }, - - _dispose() { - this._optionChangedCallbacks.empty(); - this._createDisposingAction(); - this._disposingAction(); - this._eventsStrategy.dispose(); - this._options.dispose(); - this._disposed = true; - }, - - _lockUpdate() { - this._updateLockCount++; - }, - - _unlockUpdate() { - this._updateLockCount = Math.max(this._updateLockCount - 1, 0); - }, - - // TODO: remake as getter after ES6 refactor - _isUpdateAllowed() { - return this._updateLockCount === 0; - }, - - // TODO: remake as getter after ES6 refactor - _isInitializingRequired() { - return !this._initializing && !this._initialized; - }, - - isInitialized() { - return this._initialized; - }, - - _commitUpdate() { - this.postponedOperations.callPostponedOperations(); - this._isInitializingRequired() && this._initializeComponent(); - }, - - _initializeComponent() { - this._initializing = true; - - try { - this._init(); - } finally { - this._initializing = false; - this._lockUpdate(); - this._createActionByOption('onInitialized', { excludeValidators: ['disabled', 'readOnly'] })(); - this._unlockUpdate(); - this._initialized = true; - } - }, - - instance() { - return this; - }, - - beginUpdate: function() { - this._lockUpdate(); - }, - - endUpdate: function() { - this._unlockUpdate(); - this._isUpdateAllowed() && this._commitUpdate(); - }, - - _optionChanging: noop, - - _notifyOptionChanged(option, value, previousValue) { - if(this._initialized) { - const optionNames = [option].concat(this._options.getAliasesByName(option)); - for(let i = 0; i < optionNames.length; i++) { - const name = optionNames[i]; - const args = { - name: getPathParts(name)[0], - fullName: name, - value: value, - previousValue: previousValue - }; - - if(!isInnerOption(name)) { - this._optionChangedCallbacks.fireWith(this, [extend(this._defaultActionArgs(), args)]); - this._optionChangedAction(extend({}, args)); - } - - if(!this._disposed && this._cancelOptionChange !== name) { - this._optionChanged(args); - } - } - } - }, - - initialOption(name) { - return this._options.initial(name); - }, - - _defaultActionConfig() { - return { - context: this, - component: this - }; - }, - - _defaultActionArgs() { - return { - component: this - }; - }, - - _createAction(actionSource, config) { - let action; - - return (e) => { - if(!isDefined(e)) { - e = {}; - } - - if(!isPlainObject(e)) { - e = { actionValue: e }; - } - action = action || new Action(actionSource, extend({}, config, this._defaultActionConfig())); - - return action.execute.call(action, extend(e, this._defaultActionArgs())); - }; - }, - - _createActionByOption(optionName, config) { - let action; - let eventName; - let actionFunc; - - config = extend({}, config); - - const result = (...args) => { - if(!eventName) { - config = config || {}; - - if(typeof optionName !== 'string') { - throw errors.Error('E0008'); - } - - if(optionName.indexOf('on') === 0) { - eventName = getEventName(optionName); - } - ///#DEBUG - if(optionName.indexOf('on') !== 0) { - throw Error(`The '${optionName}' option name should start with 'on' prefix`); - } - ///#ENDDEBUG - - actionFunc = this.option(optionName); - } - - if(!action && !actionFunc && !config.beforeExecute && !config.afterExecute && !this._eventsStrategy.hasEvent(eventName)) { - return; - } - - if(!action) { - const beforeExecute = config.beforeExecute; - config.beforeExecute = (...props) => { - beforeExecute && beforeExecute.apply(this, props); - this._eventsStrategy.fireEvent(eventName, props[0].args); - }; - action = this._createAction(actionFunc, config); - } - - if(Config().wrapActionsBeforeExecute) { - const beforeActionExecute = this.option('beforeActionExecute') || noop; - const wrappedAction = beforeActionExecute(this, action, config) || action; - return wrappedAction.apply(this, args); - } - - return action.apply(this, args); - }; - - if(Config().wrapActionsBeforeExecute) { - return result; - } - - const onActionCreated = this.option('onActionCreated') || noop; - - return onActionCreated(this, result, config) || result; - }, - - on(eventName, eventHandler) { - this._eventsStrategy.on(eventName, eventHandler); - return this; - }, - - off(eventName, eventHandler) { - this._eventsStrategy.off(eventName, eventHandler); - return this; - }, - - hasActionSubscription: function(actionName) { - return !!this._options.silent(actionName) || - this._eventsStrategy.hasEvent(getEventName(actionName)); - }, - - isOptionDeprecated(name) { - return this._options.isDeprecated(name); - }, - - _setOptionWithoutOptionChange(name, value) { - this._cancelOptionChange = name; - this.option(name, value); - this._cancelOptionChange = false; - }, - - _getOptionValue(name, context) { - const value = this.option(name); - - if(isFunction(value)) { - return value.bind(context)(); - } - - return value; - }, - - option(...args) { - return this._options.option(...args); - }, - - resetOption(name) { - this.beginUpdate(); - this._options.reset(name); - this.endUpdate(); - } -}); +export * from '../__internal/core/widget/component'; + +/** + * @name Component.ctor + * @publicName ctor(options) + * @param1 options:ComponentOptions|undefined + * @hidden + */ diff --git a/packages/devextreme/js/core/dom_component.js b/packages/devextreme/js/core/dom_component.js index 08c2a2e82bf9..8efcf59e40df 100644 --- a/packages/devextreme/js/core/dom_component.js +++ b/packages/devextreme/js/core/dom_component.js @@ -1,504 +1,9 @@ -import $ from '../core/renderer'; -import config from './config'; -import errors from './errors'; -import windowResizeCallbacks from '../core/utils/resize_callbacks'; -import { Component } from './component'; -import { TemplateManager } from './template_manager'; -import { attachInstanceToElement, getInstanceByElement } from './utils/public_component'; -import { addShadowDomStyles } from './utils/shadow_dom'; -import { cleanDataRecursive } from './element_data'; -import { each } from './utils/iterator'; -import { extend } from './utils/extend'; -import { getPublicElement } from '../core/element'; -import { grep, noop } from './utils/common'; -import { isString, isDefined, isFunction } from './utils/type'; -import { hasWindow } from '../core/utils/window'; -import { resize as resizeEvent, visibility as visibilityEvents } from '../events/short'; -import license, { peekValidationPerformed } from '../__internal/core/license/license_validation'; - -const { abstract } = Component; - -const DOMComponent = Component.inherit({ - _getDefaultOptions() { - return extend(this.callBase(), { - - width: undefined, - - height: undefined, - - rtlEnabled: config().rtlEnabled, - - elementAttr: {}, - - disabled: false, - - integrationOptions: {} - }, this._useTemplates() ? TemplateManager.createDefaultOptions() : {}); - }, - /** - * @name DOMComponent.ctor - * @publicName ctor(element,options) - * @param1 element:Element|JQuery - * @param2 options:DOMComponentOptions|undefined - * @hidden - */ - ctor(element, options) { - this._customClass = null; - - this._createElement(element); - attachInstanceToElement(this._$element, this, this._dispose); - - this.callBase(options); - const validationAlreadyPerformed = peekValidationPerformed(); - license.validateLicense(config().licenseKey); - if(!validationAlreadyPerformed && peekValidationPerformed()) { - config({ licenseKey: '' }); - } - }, - - _createElement(element) { - this._$element = $(element); - }, - - _getSynchronizableOptionsForCreateComponent() { - return ['rtlEnabled', 'disabled', 'templatesRenderAsynchronously']; - }, - - _checkFunctionValueDeprecation: function(optionNames) { - if(!this.option('_ignoreFunctionValueDeprecation')) { - optionNames.forEach(optionName => { - if(isFunction(this.option(optionName))) { - errors.log('W0017', optionName); - } - }); - } - }, - - _visibilityChanged: abstract, - _dimensionChanged: abstract, - - _init() { - this.callBase(); - this._checkFunctionValueDeprecation([ - 'width', 'height', - 'maxHeight', 'maxWidth', - 'minHeight', 'minWidth', - 'popupHeight', 'popupWidth' - ]); - this._attachWindowResizeCallback(); - this._initTemplateManager(); - }, - - _setOptionsByDevice(instanceCustomRules) { - this.callBase([].concat(this.constructor._classCustomRules || [], instanceCustomRules || [])); - }, - - _isInitialOptionValue(name) { - const isCustomOption = this.constructor._classCustomRules - && Object.prototype.hasOwnProperty.call(this._convertRulesToOptions(this.constructor._classCustomRules), name); - - return !isCustomOption && this.callBase(name); - }, - - _attachWindowResizeCallback() { - if(this._isDimensionChangeSupported()) { - const windowResizeCallBack = this._windowResizeCallBack = this._dimensionChanged.bind(this); - - windowResizeCallbacks.add(windowResizeCallBack); - } - }, - - _isDimensionChangeSupported() { - return this._dimensionChanged !== abstract; - }, - - _renderComponent() { - addShadowDomStyles(this.$element()); - - this._initMarkup(); - - hasWindow() && this._render(); - }, - - _initMarkup() { - const { rtlEnabled } = this.option() || {}; - - this._renderElementAttributes(); - this._toggleRTLDirection(rtlEnabled); - this._renderVisibilityChange(); - this._renderDimensions(); - }, - - _render() { - this._attachVisibilityChangeHandlers(); - }, - - _renderElementAttributes() { - const { elementAttr } = this.option() || {}; - const attributes = extend({}, elementAttr); - const classNames = attributes.class; - - delete attributes.class; - - this.$element() - .attr(attributes) - .removeClass(this._customClass) - .addClass(classNames); - - this._customClass = classNames; - }, - - _renderVisibilityChange() { - if(this._isDimensionChangeSupported()) { - this._attachDimensionChangeHandlers(); - } - - if(this._isVisibilityChangeSupported()) { - const $element = this.$element(); - - $element.addClass('dx-visibility-change-handler'); - } - }, - - _renderDimensions() { - const $element = this.$element(); - const element = $element.get(0); - const width = this._getOptionValue('width', element); - const height = this._getOptionValue('height', element); - - if(this._isCssUpdateRequired(element, height, width)) { - $element.css({ - width: width === null ? '' : width, - height: height === null ? '' : height - }); - } - }, - - _isCssUpdateRequired(element, height, width) { - return !!(isDefined(width) || isDefined(height) || element.style.width || element.style.height); - }, - - _attachDimensionChangeHandlers() { - const $el = this.$element(); - const namespace = `${this.NAME}VisibilityChange`; - - resizeEvent.off($el, { namespace }); - resizeEvent.on($el, () => this._dimensionChanged(), { namespace }); - }, - - _attachVisibilityChangeHandlers() { - if(this._isVisibilityChangeSupported()) { - const $el = this.$element(); - const namespace = `${this.NAME}VisibilityChange`; - - this._isHidden = !this._isVisible(); - visibilityEvents.off($el, { namespace }); - visibilityEvents.on($el, - () => this._checkVisibilityChanged('shown'), - () => this._checkVisibilityChanged('hiding'), - { namespace } - ); - } - }, - - _isVisible() { - const $element = this.$element(); - - return $element.is(':visible'); - }, - - _checkVisibilityChanged(action) { - const isVisible = this._isVisible(); - - if(isVisible) { - if(action === 'hiding' && !this._isHidden) { - this._visibilityChanged(false); - this._isHidden = true; - } else if(action === 'shown' && this._isHidden) { - this._isHidden = false; - this._visibilityChanged(true); - } - } - }, - - _isVisibilityChangeSupported() { - return this._visibilityChanged !== abstract && hasWindow(); - }, - - _clean: noop, - - _modelByElement() { - const { modelByElement } = this.option(); - const $element = this.$element(); - - return modelByElement ? modelByElement($element) : undefined; - }, - - _invalidate() { - if(this._isUpdateAllowed()) { - throw errors.Error('E0007'); - } - - this._requireRefresh = true; - }, - - _refresh() { - this._clean(); - this._renderComponent(); - }, - - _dispose() { - this._templateManager && this._templateManager.dispose(); - this.callBase(); - this._clean(); - this._detachWindowResizeCallback(); - }, - - _detachWindowResizeCallback() { - if(this._isDimensionChangeSupported()) { - windowResizeCallbacks.remove(this._windowResizeCallBack); - } - }, - - _toggleRTLDirection(rtl) { - const $element = this.$element(); - - $element.toggleClass('dx-rtl', rtl); - }, - - _createComponent(element, component, config = {}) { - const synchronizableOptions = grep( - this._getSynchronizableOptionsForCreateComponent(), - value => !(value in config) - ); - - const { integrationOptions } = this.option(); - let { nestedComponentOptions } = this.option(); - - nestedComponentOptions = nestedComponentOptions || noop; - - const nestedComponentConfig = extend( - { integrationOptions }, - nestedComponentOptions(this) - ); - - synchronizableOptions.forEach(optionName => - nestedComponentConfig[optionName] = this.option(optionName) - ); - - this._extendConfig(config, nestedComponentConfig); - - let instance = void 0; - - if(isString(component)) { - const $element = $(element)[component](config); - - instance = $element[component]('instance'); - } else if(element) { - instance = component.getInstance(element); - - if(instance) { - instance.option(config); - } else { - instance = new component(element, config); - } - } - - if(instance) { - const optionChangedHandler = ({ name, value }) => { - if(synchronizableOptions.includes(name)) { - instance.option(name, value); - } - }; - - this.on('optionChanged', optionChangedHandler); - instance.on('disposing', () => this.off('optionChanged', optionChangedHandler)); - } - - return instance; - }, - - _extendConfig(config, extendConfig) { - each(extendConfig, (key, value) => { - !Object.prototype.hasOwnProperty.call(config, key) && (config[key] = value); - }); - }, - - _defaultActionConfig() { - const $element = this.$element(); - const context = this._modelByElement($element); - - return extend(this.callBase(), { context }); - }, - - _defaultActionArgs() { - const $element = this.$element(); - const model = this._modelByElement($element); - const element = this.element(); - - return extend(this.callBase(), { element, model }); - }, - - _optionChanged(args) { - switch(args.name) { - case 'width': - case 'height': - this._renderDimensions(); - break; - case 'rtlEnabled': - this._invalidate(); - break; - case 'elementAttr': - this._renderElementAttributes(); - break; - case 'disabled': - case 'integrationOptions': - break; - default: - this.callBase(args); - break; - } - }, - - _removeAttributes(element) { - const attrs = element.attributes; - - for(let i = attrs.length - 1; i >= 0; i--) { - const attr = attrs[i]; - - if(attr) { - const { name } = attr; - - if(!name.indexOf('aria-') || name.indexOf('dx-') !== -1 || - name === 'role' || name === 'style' || name === 'tabindex') { - element.removeAttribute(name); - } - } - } - }, - - _removeClasses(element) { - element.className = element.className - .split(' ') - .filter(cssClass => cssClass.lastIndexOf('dx-', 0) !== 0) - .join(' '); - }, - - _updateDOMComponent(renderRequired) { - if(renderRequired) { - this._renderComponent(); - } else if(this._requireRefresh) { - this._requireRefresh = false; - this._refresh(); - } - }, - - endUpdate() { - const renderRequired = this._isInitializingRequired(); - - this.callBase(); - this._isUpdateAllowed() && this._updateDOMComponent(renderRequired); - }, - - $element() { - return this._$element; - }, - - element() { - const $element = this.$element(); - - return getPublicElement($element); - }, - - dispose() { - const element = this.$element().get(0); - - cleanDataRecursive(element, true); - element.textContent = ''; - this._removeAttributes(element); - this._removeClasses(element); - }, - - resetOption(optionName) { - this.callBase(optionName); - - if(optionName === 'width' || optionName === 'height') { - const initialOption = this.initialOption(optionName); - - !isDefined(initialOption) && this.$element().css(optionName, ''); - } - }, - - _getAnonymousTemplateName() { - return void 0; - }, - - _initTemplateManager() { - if(this._templateManager || !this._useTemplates()) return void 0; - - const { integrationOptions = {} } = this.option(); - const { createTemplate } = integrationOptions; - - this._templateManager = new TemplateManager( - createTemplate, - this._getAnonymousTemplateName() - ); - this._initTemplates(); - }, - - _initTemplates() { - const { templates, anonymousTemplateMeta } = this._templateManager.extractTemplates(this.$element()); - const anonymousTemplate = this.option(`integrationOptions.templates.${anonymousTemplateMeta.name}`); - - templates.forEach(({ name, template }) => { - this._options.silent(`integrationOptions.templates.${name}`, template); - }); - - if(anonymousTemplateMeta.name && !anonymousTemplate) { - this._options.silent(`integrationOptions.templates.${anonymousTemplateMeta.name}`, anonymousTemplateMeta.template); - this._options.silent('_hasAnonymousTemplateContent', true); - } - }, - - _getTemplateByOption(optionName) { - return this._getTemplate(this.option(optionName)); - }, - - _getTemplate(templateSource) { - const templates = this.option('integrationOptions.templates'); - const isAsyncTemplate = this.option('templatesRenderAsynchronously'); - const skipTemplates = this.option('integrationOptions.skipTemplates'); - - return this._templateManager.getTemplate( - templateSource, - templates, - { - isAsyncTemplate, - skipTemplates - }, - this - ); - }, - - _saveTemplate(name, template) { - this._setOptionWithoutOptionChange( - 'integrationOptions.templates.' + name, - this._templateManager._createTemplate(template) - ); - }, - - _useTemplates() { - return true; - }, -}); - -DOMComponent.getInstance = function(element) { - return getInstanceByElement($(element), this); -}; - -DOMComponent.defaultOptions = function(rule) { - this._classCustomRules = this._classCustomRules || []; - this._classCustomRules.push(rule); -}; - +import DOMComponent from '../__internal/core/widget/dom_component'; export default DOMComponent; +/** + * @name DOMComponent.ctor + * @publicName ctor(element,options) + * @param1 element:Element|JQuery + * @param2 options:DOMComponentOptions|undefined + * @hidden + */ diff --git a/packages/devextreme/js/renovation/ui/common/__tests__/widget.test.tsx b/packages/devextreme/js/renovation/ui/common/__tests__/widget.test.tsx index afcbf47197ab..463d5ef96876 100644 --- a/packages/devextreme/js/renovation/ui/common/__tests__/widget.test.tsx +++ b/packages/devextreme/js/renovation/ui/common/__tests__/widget.test.tsx @@ -10,7 +10,7 @@ import { } from '../../../test_utils/events_mock'; import { Widget, viewFunction, WidgetProps } from '../widget'; import { ConfigProvider } from '../../../common/config_provider'; -import { resolveRtlEnabled, resolveRtlEnabledDefinition } from '../../../utils/resolve_rtl'; +import { resolveRtlEnabled, resolveRtlEnabledDefinition } from '../../../../__internal/core/r1/utils/resolve_rtl'; import resizeCallbacks from '../../../../core/utils/resize_callbacks'; import errors from '../../../../core/errors'; import domAdapter from '../../../../core/dom_adapter'; @@ -19,7 +19,7 @@ jest.mock('../../../../events/utils/index', () => ({ ...jest.requireActual('../../../../events/utils/index'), })); jest.mock('../../../common/config_provider', () => ({ ConfigProvider: () => null })); -jest.mock('../../../utils/resolve_rtl'); +jest.mock('../../../../__internal/core/r1/utils/resolve_rtl'); jest.mock('../../../../core/utils/resize_callbacks'); jest.mock('../../../../core/errors'); diff --git a/packages/devextreme/js/renovation/ui/common/widget.tsx b/packages/devextreme/js/renovation/ui/common/widget.tsx index 4d9acbecd1ee..effb8e8b4707 100644 --- a/packages/devextreme/js/renovation/ui/common/widget.tsx +++ b/packages/devextreme/js/renovation/ui/common/widget.tsx @@ -35,7 +35,7 @@ import { BaseWidgetProps } from './base_props'; import { EffectReturn } from '../../utils/effect_return'; import { ConfigContextValue, ConfigContext } from '../../../__internal/core/r1/config_context'; import { ConfigProvider } from '../../common/config_provider'; -import { resolveRtlEnabled, resolveRtlEnabledDefinition } from '../../utils/resolve_rtl'; +import { resolveRtlEnabled, resolveRtlEnabledDefinition } from '../../../__internal/core/r1/utils/resolve_rtl'; import resizeCallbacks from '../../../core/utils/resize_callbacks'; import errors from '../../../core/errors'; import domAdapter from '../../../core/dom_adapter'; diff --git a/packages/devextreme/js/renovation/ui/scroll_view/scrollable.tsx b/packages/devextreme/js/renovation/ui/scroll_view/scrollable.tsx index f0eb5cf1713e..17162cb18552 100644 --- a/packages/devextreme/js/renovation/ui/scroll_view/scrollable.tsx +++ b/packages/devextreme/js/renovation/ui/scroll_view/scrollable.tsx @@ -26,7 +26,7 @@ import { hasWindow } from '../../../core/utils/window'; import { DIRECTION_HORIZONTAL, DIRECTION_VERTICAL } from './common/consts'; import { ScrollableProps } from './common/scrollable_props'; -import { resolveRtlEnabled } from '../../utils/resolve_rtl'; +import { resolveRtlEnabled } from '../../../__internal/core/r1/utils/resolve_rtl'; import { ConfigContextValue, ConfigContext } from '../../../__internal/core/r1/config_context'; export const viewFunction = (viewModel: Scrollable): JSX.Element => { diff --git a/packages/devextreme/js/renovation/ui/toolbar/toolbar.tsx b/packages/devextreme/js/renovation/ui/toolbar/toolbar.tsx index 46327dc34e48..976e8f09fcbb 100644 --- a/packages/devextreme/js/renovation/ui/toolbar/toolbar.tsx +++ b/packages/devextreme/js/renovation/ui/toolbar/toolbar.tsx @@ -8,7 +8,7 @@ import { DomComponentWrapper } from '../common/dom_component_wrapper'; import { BaseToolbarItemProps, ToolbarProps } from './toolbar_props'; import { isObject } from '../../../core/utils/type'; import { ConfigContext, ConfigContextValue } from '../../../__internal/core/r1/config_context'; -import { resolveRtlEnabled } from '../../utils/resolve_rtl'; +import { resolveRtlEnabled } from '../../../__internal/core/r1/utils/resolve_rtl'; export const viewFunction = ({ componentProps, restAttributes }: Toolbar): JSX.Element => ( { - it('should return value from props if props has value', () => { - // emulate context - const contextConfig = { rtlEnabled: true } as ConfigContextValue; - - expect(resolveRtlEnabled(false, contextConfig)).toBe(false); - }); - - it('should return value from parent rtlEnabled context if props isnt defined', () => { - // emulate context - const contextConfig = { rtlEnabled: true } as ConfigContextValue; - expect(resolveRtlEnabled(undefined, contextConfig)).toBe(true); - }); - - it('should return value from config if any other props isnt defined', () => { - config().rtlEnabled = true; - expect(resolveRtlEnabled(undefined, undefined)).toBe(true); - }); -}); - -describe('rtlEnabledDefinition', () => { - each` - global | rtlEnabled | parentRtlEnabled | expected - ${true} | ${true} | ${true} | ${false} - ${undefined} | ${undefined} | ${undefined} | ${false} - ${true} | ${true} | ${undefined} | ${true} - ${true} | ${false} | ${undefined} | ${true} - ${true} | ${true} | ${false} | ${true} - ${true} | ${false} | ${true} | ${true} - ${true} | ${undefined} | ${undefined} | ${true} - ${true} | ${undefined} | ${true} | ${false} - ${true} | ${undefined} | ${false} | ${false} - ${true} | ${true} | ${true} | ${false} - ` - .describe('resolveRtlEnabledDefinition truth table', ({ - global, rtlEnabled, parentRtlEnabled, expected, - }) => { - const name = `${JSON.stringify({ - global, rtlEnabled, parentRtlEnabled, expected, - })}`; - - it(name, () => { - config().rtlEnabled = global; - const contextConfig = { rtlEnabled: parentRtlEnabled } as ConfigContextValue; - expect(resolveRtlEnabledDefinition(rtlEnabled, contextConfig)).toBe(expected); - }); - }); - - it('should process undefined config', () => { - config().rtlEnabled = true; - expect(resolveRtlEnabledDefinition(undefined, undefined)).toBe(true); - }); -}); diff --git a/packages/devextreme/js/renovation/utils/resolve_rtl.ts b/packages/devextreme/js/renovation/utils/resolve_rtl.ts deleted file mode 100644 index ca1c365be8b9..000000000000 --- a/packages/devextreme/js/renovation/utils/resolve_rtl.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ConfigContextValue } from '../../__internal/core/r1/config_context'; -import { isDefined } from '../../core/utils/type'; -import globalConfig from '../../core/config'; - -export function resolveRtlEnabled(rtlProp?: boolean, config?: ConfigContextValue): -boolean | undefined { - if (rtlProp !== undefined) { - return rtlProp; - } - if (config?.rtlEnabled !== undefined) { - return config.rtlEnabled; - } - return globalConfig().rtlEnabled; -} - -export function resolveRtlEnabledDefinition(rtlProp?: boolean, config?: ConfigContextValue): -boolean { - const isPropDefined = isDefined(rtlProp); - const onlyGlobalDefined = isDefined(globalConfig().rtlEnabled) - && !isPropDefined && !isDefined(config?.rtlEnabled); - return (isPropDefined - && (rtlProp !== config?.rtlEnabled)) - || onlyGlobalDefined; -} diff --git a/packages/devextreme/js/ui/chat.d.ts b/packages/devextreme/js/ui/chat.d.ts index 4f855992c697..5ec786dbd800 100644 --- a/packages/devextreme/js/ui/chat.d.ts +++ b/packages/devextreme/js/ui/chat.d.ts @@ -1,6 +1,5 @@ import Widget, { WidgetOptions } from './widget/ui.widget'; import { - Cancelable, EventInfo, NativeEventInfo, InitializedEventInfo, @@ -35,9 +34,14 @@ export type OptionChangedEvent = EventInfo & ChangedOptionInfo; * @docid _ui_chat_MessageSendEvent * @public * @type object - * @inherits Cancelable,NativeEventInfo,Message + * @inherits NativeEventInfo */ -export type MessageSendEvent = Cancelable & NativeEventInfo & Message; +export type MessageSendEvent = NativeEventInfo & { + /** + * @docid + */ + readonly message?: Message; +}; /** * @docid @@ -138,7 +142,14 @@ export interface dxChatOptions extends WidgetOptions { * @namespace DevExpress.ui * @public */ -export default class dxChat extends Widget { } +export default class dxChat extends Widget { + /** + * @docid + * @publicName renderMessage(message) + * @public + */ + renderMessage(message: Message): void; +} /** @public */ export type ExplicitTypes = { diff --git a/packages/devextreme/js/ui/chat.js b/packages/devextreme/js/ui/chat.js index d1e07289a2e1..a480658156d6 100644 --- a/packages/devextreme/js/ui/chat.js +++ b/packages/devextreme/js/ui/chat.js @@ -5,32 +5,18 @@ export default Chat; // STYLE chat /** - * @name dxChatOptions.accessKey - * @hidden - */ - -/** - * @name dxChatOptions.activeStateEnabled - * @hidden - */ - -/** - * @name dxChatOptions.focusStateEnabled + * @name dxChatOptions.onContentReady * @hidden + * @action */ /** - * @name dxChatOptions.hint + * @name dxChatOptions.tabIndex * @hidden */ /** - * @name dxChatOptions.onContentReady - * @hidden true - * @action - */ - -/** - * @name dxChatOptions.tabIndex + * @name dxChatOptions.registerKeyHandler + * @publicName registerKeyHandler(key, handler) * @hidden */ diff --git a/packages/devextreme/js/ui/data_grid.d.ts b/packages/devextreme/js/ui/data_grid.d.ts index 1f3bf33543bc..005296befda0 100644 --- a/packages/devextreme/js/ui/data_grid.d.ts +++ b/packages/devextreme/js/ui/data_grid.d.ts @@ -1988,6 +1988,9 @@ export type Scrolling = ScrollingBase & { */ export type dxDataGridSelection = Selection; +/** @public */ +export type SelectionSensitivity = 'base' | 'accent' | 'case' | 'variant'; + /** @public */ export type Selection = SelectionBase & { /** @@ -1996,6 +1999,12 @@ export type Selection = SelectionBase & { * @public */ deferred?: boolean; + /** + * @docid dxDataGridOptions.selection.sensitivity + * @default "base" + * @public + */ + sensitivity?: SelectionSensitivity; /** * @docid dxDataGridOptions.selection.selectAllMode * @default "allPages" diff --git a/packages/devextreme/js/ui/data_grid_types.d.ts b/packages/devextreme/js/ui/data_grid_types.d.ts index 4bcc03c0a207..3a0da75abac5 100644 --- a/packages/devextreme/js/ui/data_grid_types.d.ts +++ b/packages/devextreme/js/ui/data_grid_types.d.ts @@ -143,6 +143,7 @@ export { Editing, EditingTexts, Scrolling, + SelectionSensitivity, Selection, Column, ColumnButton, diff --git a/packages/devextreme/js/ui/widget/ui.widget.js b/packages/devextreme/js/ui/widget/ui.widget.js index 7c781cb169c1..3e2a5276590a 100644 --- a/packages/devextreme/js/ui/widget/ui.widget.js +++ b/packages/devextreme/js/ui/widget/ui.widget.js @@ -1,617 +1,27 @@ -import $ from '../../core/renderer'; -import Action from '../../core/action'; -import DOMComponent from '../../core/dom_component'; -import { active, focus, hover, keyboard } from '../../events/short'; -import { deferRender, deferRenderer, noop } from '../../core/utils/common'; -import { each } from '../../core/utils/iterator'; -import { extend } from '../../core/utils/extend'; -import { focusable as focusableSelector } from './selectors'; -import { isPlainObject, isDefined } from '../../core/utils/type'; -import devices from '../../core/devices'; -import { compare as compareVersions } from '../../core/utils/version'; - -import '../../events/click'; -import '../../events/core/emitter.feedback'; -import '../../events/hover'; - -function setAttribute(name, value, target) { - name = (name === 'role' || name === 'id') ? name : `aria-${name}`; - value = isDefined(value) ? value.toString() : null; - - target.attr(name, value); -} - - -const Widget = DOMComponent.inherit({ - _feedbackHideTimeout: 400, - _feedbackShowTimeout: 30, - - _supportedKeys() { - return {}; - }, - - _getDefaultOptions() { - return extend(this.callBase(), { - hoveredElement: null, - isActive: false, - - disabled: false, - - visible: true, - - hint: undefined, - - activeStateEnabled: false, - - onContentReady: null, - - hoverStateEnabled: false, - - focusStateEnabled: false, - - tabIndex: 0, - - accessKey: undefined, - - /** - * @section Utils - * @type function - * @default null - * @type_function_param1 e:object - * @type_function_param1_field1 component:this - * @type_function_param1_field2 element:DxElement - * @type_function_param1_field3 model:object - * @name WidgetOptions.onFocusIn - * @action - * @hidden - */ - onFocusIn: null, - - /** - * @section Utils - * @type function - * @default null - * @type_function_param1 e:object - * @type_function_param1_field1 component:this - * @type_function_param1_field2 element:DxElement - * @type_function_param1_field3 model:object - * @name WidgetOptions.onFocusOut - * @action - * @hidden - */ - onFocusOut: null, - onKeyboardHandled: null, - ignoreParentReadOnly: false, - useResizeObserver: true - }); - }, - - _defaultOptionsRules: function() { - return this.callBase().concat([{ - device: function() { - const device = devices.real(); - const platform = device.platform; - const version = device.version; - return platform === 'ios' && compareVersions(version, '13.3') <= 0; - }, - options: { - useResizeObserver: false - } - }]); - }, - - _init() { - this.callBase(); - this._initContentReadyAction(); - }, - - _innerWidgetOptionChanged: function(innerWidget, args) { - const options = Widget.getOptionsFromContainer(args); - innerWidget && innerWidget.option(options); - this._options.cache(args.name, options); - }, - - _bindInnerWidgetOptions(innerWidget, optionsContainer) { - const syncOptions = () => - this._options.silent(optionsContainer, extend({}, innerWidget.option())); - - syncOptions(); - innerWidget.on('optionChanged', syncOptions); - }, - - _getAriaTarget() { - return this._focusTarget(); - }, - - _initContentReadyAction() { - this._contentReadyAction = this._createActionByOption('onContentReady', { - excludeValidators: ['disabled', 'readOnly'] - }); - }, - - _initMarkup() { - const { disabled, visible } = this.option(); - - this.$element().addClass('dx-widget'); - - this._toggleDisabledState(disabled); - this._toggleVisibility(visible); - this._renderHint(); - this._isFocusable() && this._renderFocusTarget(); - - this.callBase(); - }, - - _render() { - this.callBase(); - - this._renderContent(); - this._renderFocusState(); - this._attachFeedbackEvents(); - this._attachHoverEvents(); - this._toggleIndependentState(); - }, - - _renderHint() { - const { hint } = this.option(); - - this.$element().attr('title', hint || null); - }, - - _renderContent() { - deferRender(() => !this._disposed ? this._renderContentImpl() : void 0) - .done(() => !this._disposed ? this._fireContentReadyAction() : void 0); - }, - - _renderContentImpl: noop, - - _fireContentReadyAction: deferRenderer(function() { return this._contentReadyAction(); }), - - _dispose() { - this._contentReadyAction = null; - this._detachKeyboardEvents(); - - this.callBase(); - }, - - _resetActiveState() { - this._toggleActiveState(this._eventBindingTarget(), false); - }, - - _clean() { - this._cleanFocusState(); - this._resetActiveState(); - this.callBase(); - this.$element().empty(); - }, - - _toggleVisibility(visible) { - this.$element().toggleClass('dx-state-invisible', !visible); - }, - - _renderFocusState() { - this._attachKeyboardEvents(); - - if(this._isFocusable()) { - this._renderFocusTarget(); - this._attachFocusEvents(); - this._renderAccessKey(); - } - }, - - _renderAccessKey() { - const $el = this._focusTarget(); - const { accessKey } = this.option(); - - $el.attr('accesskey', accessKey); - }, - - _isFocusable() { - const { focusStateEnabled, disabled } = this.option(); - - return focusStateEnabled && !disabled; - }, - - _eventBindingTarget() { - return this.$element(); - }, - - _focusTarget() { - return this._getActiveElement(); - }, - - _isFocusTarget: function(element) { - const focusTargets = $(this._focusTarget()).toArray(); - return focusTargets.includes(element); - }, - - _findActiveTarget($element) { - return $element.find(this._activeStateUnit).not('.dx-state-disabled'); - }, - - _getActiveElement() { - const activeElement = this._eventBindingTarget(); - - if(this._activeStateUnit) { - return this._findActiveTarget(activeElement); - } - - return activeElement; - }, - - _renderFocusTarget() { - const { tabIndex } = this.option(); - - this._focusTarget().attr('tabIndex', tabIndex); - }, - - _keyboardEventBindingTarget() { - return this._eventBindingTarget(); - }, - - _refreshFocusEvent() { - this._detachFocusEvents(); - this._attachFocusEvents(); - }, - - _focusEventTarget() { - return this._focusTarget(); - }, - - _focusInHandler(event) { - if(!event.isDefaultPrevented()) { - this._createActionByOption('onFocusIn', { - beforeExecute: () => this._updateFocusState(event, true), - excludeValidators: ['readOnly'] - })({ event }); - } - }, - - _focusOutHandler(event) { - if(!event.isDefaultPrevented()) { - this._createActionByOption('onFocusOut', { - beforeExecute: () => this._updateFocusState(event, false), - excludeValidators: ['readOnly', 'disabled'] - })({ event }); - } - }, - - _updateFocusState({ target }, isFocused) { - if(this._isFocusTarget(target)) { - this._toggleFocusClass(isFocused, $(target)); - } - }, - - _toggleFocusClass(isFocused, $element) { - const $focusTarget = $element && $element.length ? $element : this._focusTarget(); - - $focusTarget.toggleClass('dx-state-focused', isFocused); - }, - - _hasFocusClass(element) { - const $focusTarget = $(element || this._focusTarget()); - - return $focusTarget.hasClass('dx-state-focused'); - }, - - _isFocused() { - return this._hasFocusClass(); - }, - - _getKeyboardListeners() { - return []; - }, - - _attachKeyboardEvents() { - this._detachKeyboardEvents(); - - const { focusStateEnabled, onKeyboardHandled } = this.option(); - const hasChildListeners = this._getKeyboardListeners().length; - const hasKeyboardEventHandler = !!onKeyboardHandled; - const shouldAttach = focusStateEnabled || hasChildListeners || hasKeyboardEventHandler; - - if(shouldAttach) { - this._keyboardListenerId = keyboard.on( - this._keyboardEventBindingTarget(), - this._focusTarget(), - opts => this._keyboardHandler(opts) - ); - } - }, - - _keyboardHandler(options, onlyChildProcessing) { - if(!onlyChildProcessing) { - const { originalEvent, keyName, which } = options; - const keys = this._supportedKeys(originalEvent); - const func = keys[keyName] || keys[which]; - - if(func !== undefined) { - const handler = func.bind(this); - const result = handler(originalEvent, options); - - if(!result) { - return false; - } - } - } - - const keyboardListeners = this._getKeyboardListeners(); - const { onKeyboardHandled } = this.option(); - - keyboardListeners.forEach(listener => listener && listener._keyboardHandler(options)); - - onKeyboardHandled && onKeyboardHandled(options); - - return true; - }, - - _refreshFocusState() { - this._cleanFocusState(); - this._renderFocusState(); - }, - - _cleanFocusState() { - const $element = this._focusTarget(); - - $element.removeAttr('tabIndex'); - this._toggleFocusClass(false); - this._detachFocusEvents(); - this._detachKeyboardEvents(); - }, - - _detachKeyboardEvents() { - keyboard.off(this._keyboardListenerId); - this._keyboardListenerId = null; - }, - - _attachHoverEvents() { - const { hoverStateEnabled } = this.option(); - const selector = this._activeStateUnit; - const namespace = 'UIFeedback'; - const $el = this._eventBindingTarget(); - - hover.off($el, { selector, namespace }); - - if(hoverStateEnabled) { - hover.on($el, new Action(({ event, element }) => { - this._hoverStartHandler(event); - this.option('hoveredElement', $(element)); - }, { excludeValidators: ['readOnly'] }), event => { - this.option('hoveredElement', null); - this._hoverEndHandler(event); - }, { selector, namespace }); - } - }, - - _attachFeedbackEvents() { - const { activeStateEnabled } = this.option(); - const selector = this._activeStateUnit; - const namespace = 'UIFeedback'; - const $el = this._eventBindingTarget(); - - active.off($el, { namespace, selector }); - - if(activeStateEnabled) { - active.on($el, - new Action(({ event, element }) => this._toggleActiveState($(element), true, event)), - new Action(({ event, element }) => this._toggleActiveState($(element), false, event), - { excludeValidators: ['disabled', 'readOnly'] } - ), { - showTimeout: this._feedbackShowTimeout, - hideTimeout: this._feedbackHideTimeout, - selector, - namespace - } - ); - } - }, - - _detachFocusEvents() { - const $el = this._focusEventTarget(); - - focus.off($el, { namespace: `${this.NAME}Focus` }); - }, - - _attachFocusEvents() { - const $el = this._focusEventTarget(); - - focus.on($el, - e => this._focusInHandler(e), - e => this._focusOutHandler(e), { - namespace: `${this.NAME}Focus`, - isFocusable: (index, el) => $(el).is(focusableSelector) - } - ); - }, - - _hoverStartHandler: noop, - _hoverEndHandler: noop, - - _toggleActiveState($element, value) { - this.option('isActive', value); - $element.toggleClass('dx-state-active', value); - }, - - _updatedHover() { - const hoveredElement = this._options.silent('hoveredElement'); - - this._hover(hoveredElement, hoveredElement); - }, - - _findHoverTarget($el) { - return $el && $el.closest(this._activeStateUnit || this._eventBindingTarget()); - }, - - _hover($el, $previous) { - const { hoverStateEnabled, disabled, isActive } = this.option(); - - $previous = this._findHoverTarget($previous); - $previous && $previous.toggleClass('dx-state-hover', false); - - if($el && hoverStateEnabled && !disabled && !isActive) { - const newHoveredElement = this._findHoverTarget($el); - - newHoveredElement && newHoveredElement.toggleClass('dx-state-hover', true); - } - }, - - _toggleDisabledState(value) { - this.$element().toggleClass('dx-state-disabled', Boolean(value)); - this.setAria('disabled', value || undefined); - }, - - _toggleIndependentState() { - this.$element().toggleClass('dx-state-independent', this.option('ignoreParentReadOnly')); - }, - - _setWidgetOption(widgetName, args) { - if(!this[widgetName]) { - return; - } - - if(isPlainObject(args[0])) { - each(args[0], (option, value) => this._setWidgetOption(widgetName, [option, value])); - - return; - } - - const optionName = args[0]; - let value = args[1]; - - if(args.length === 1) { - value = this.option(optionName); - } - - const widgetOptionMap = this[`${widgetName}OptionMap`]; - - this[widgetName].option(widgetOptionMap ? widgetOptionMap(optionName) : optionName, value); - }, - - _optionChanged(args) { - const { name, value, previousValue } = args; - - switch(name) { - case 'disabled': - this._toggleDisabledState(value); - this._updatedHover(); - this._refreshFocusState(); - break; - case 'hint': - this._renderHint(); - break; - case 'ignoreParentReadOnly': - this._toggleIndependentState(); - break; - case 'activeStateEnabled': - this._attachFeedbackEvents(); - break; - case 'hoverStateEnabled': - this._attachHoverEvents(); - this._updatedHover(); - break; - case 'tabIndex': - case 'focusStateEnabled': - this._refreshFocusState(); - break; - case 'onFocusIn': - case 'onFocusOut': - case 'useResizeObserver': - break; - case 'accessKey': - this._renderAccessKey(); - break; - case 'hoveredElement': - this._hover(value, previousValue); - break; - case 'isActive': - this._updatedHover(); - break; - case 'visible': - this._toggleVisibility(value); - if(this._isVisibilityChangeSupported()) { - // TODO hiding works wrong - this._checkVisibilityChanged(value ? 'shown' : 'hiding'); - } - break; - case 'onKeyboardHandled': - this._attachKeyboardEvents(); - break; - case 'onContentReady': - this._initContentReadyAction(); - break; - default: - this.callBase(args); - } - }, - - _isVisible() { - const { visible } = this.option(); - - return this.callBase() && visible; - }, - - beginUpdate() { - this._ready(false); - this.callBase(); - }, - - endUpdate() { - this.callBase(); - - if(this._initialized) { - this._ready(true); - } - }, - - _ready(value) { - if(arguments.length === 0) { - return this._isReady; - } - - this._isReady = value; - }, - - setAria(...args) { - if(!isPlainObject(args[0])) { - setAttribute(args[0], args[1], args[2] || this._getAriaTarget()); - } else { - const target = args[1] || this._getAriaTarget(); - - each(args[0], (name, value) => setAttribute(name, value, target)); - } - }, - - isReady() { - return this._ready(); - }, - - repaint() { - this._refresh(); - }, - - focus() { - focus.trigger(this._focusTarget()); - }, - - registerKeyHandler(key, handler) { - const currentKeys = this._supportedKeys(); - - this._supportedKeys = () => extend(currentKeys, { [key]: handler }); - } -}); - -Widget.getOptionsFromContainer = ({ name, fullName, value }) => { - let options = {}; - - if(name === fullName) { - options = value; - } else { - const option = fullName.split('.').pop(); - - options[option] = value; - } - - return options; -}; - +import Widget from '../../__internal/core/widget/widget'; export default Widget; + +/** + * @section Utils + * @type function + * @default null + * @type_function_param1 e:object + * @type_function_param1_field1 component:this + * @type_function_param1_field2 element:DxElement + * @type_function_param1_field3 model:object + * @name WidgetOptions.onFocusIn + * @action + * @hidden + */ +/** + * @section Utils + * @type function + * @default null + * @type_function_param1 e:object + * @type_function_param1_field1 component:this + * @type_function_param1_field2 element:DxElement + * @type_function_param1_field3 model:object + * @name WidgetOptions.onFocusOut + * @action + * @hidden + */ diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/headerFilter.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/headerFilter.tests.js index 1a1e4895ae39..eda9ba9a35d0 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/headerFilter.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/headerFilter.tests.js @@ -4848,11 +4848,11 @@ QUnit.module('Header Filter with real columnsController', { this.headerFilterController.showHeaderFilterMenu(0); const $popupContent = $(this.headerFilterView.getPopupContainer().$overlayContent()); - const checkbox = $popupContent.find('.dx-checkbox').first().dxCheckBox('instance'); - const applyButton = $popupContent.find('.dx-button').first(); + const $checkbox = $popupContent.find('.dx-checkbox').first(); + const $applyButton = $popupContent.find('.dx-button').first(); - checkbox.option('value', true); - applyButton.trigger('dxclick'); + $checkbox.trigger('dxclick'); + $applyButton.trigger('dxclick'); this.clock.tick(500); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/tagBox.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/tagBox.tests.js index 42a5c33fee92..f29aa5aada00 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/tagBox.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/tagBox.tests.js @@ -4499,8 +4499,8 @@ QUnit.module('searchEnabled', moduleSetup, () => { this.clock.tick(TIME_TO_WAIT * 3); const $selectAllCheckbox = $(tagBox._list.$element().find(`.${SELECT_ALL_CHECKBOX_CLASS}`).eq(0)); $selectAllCheckbox.trigger('dxclick'); - $selectAllCheckbox.trigger('dxclick'); this.clock.tick(TIME_TO_WAIT * 4); + $selectAllCheckbox.trigger('dxclick'); const $tagContainer = $tagBox.find(`.${TAGBOX_TAG_CONTAINER_CLASS}`); assert.strictEqual($tagContainer.find(`.${TAGBOX_TAG_CONTENT_CLASS}`).length, 0, 'no tags'); @@ -6929,7 +6929,7 @@ QUnit.module('performance', () => { this.resetGetterCallCount(); $(`.${SELECT_ALL_CHECKBOX_CLASS}`).trigger('dxclick'); - assert.strictEqual(this.getValueGetterCallCount(), 6254, 'key getter call count'); + assert.strictEqual(this.getValueGetterCallCount(), 6154, 'key getter call count'); assert.strictEqual(isValueEqualsSpy.callCount, 5050, '_isValueEquals call count'); }); @@ -6940,7 +6940,7 @@ QUnit.module('performance', () => { const checkboxes = $(`.${LIST_CHECKBOX_CLASS}`); checkboxes.eq(checkboxes.length - 1).trigger('dxclick'); - assert.strictEqual(this.getValueGetterCallCount(), 6052, 'key getter call count'); + assert.strictEqual(this.getValueGetterCallCount(), 6054, 'key getter call count'); }); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/fieldChooser.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/fieldChooser.tests.js index fb36a4d93919..d6ab5e484d38 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/fieldChooser.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/fieldChooser.tests.js @@ -1382,7 +1382,7 @@ QUnit.module('dxPivotGridFieldChooser', { this.clock.tick(500); const $filterMenuList = $('.dx-header-filter-menu .dx-list'); - $filterMenuList.find('.dx-list-select-all-checkbox').dxCheckBox('instance').option('value', true); + $filterMenuList.find('.dx-list-select-all-checkbox').trigger('dxclick'); // act $filterMenuList.dxScrollView('option', 'onReachBottom')(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.appointmentCollector.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.appointmentCollector.tests.js index d3d78988a537..a7d60dbb6649 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.appointmentCollector.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/integration.appointmentCollector.tests.js @@ -120,15 +120,6 @@ module('Integration: Appointments Collector Base Tests', baseConfig, () => { assert.ok($collector.dxButton('instance'), 'Container is button'); }); - test('Appointment collector should be painted', function(assert) { - const widgetMock = createWidget(); - const color = '#0000ff'; - - const $collector = renderAppointmentsCollectorContainer({ widgetMock, color }); - - assert.equal(new Color($collector.css('backgroundColor')).toHex(), color, 'Color is OK'); - }); - test('Appointment collector should not be painted if items have different colors', function(assert) { const widgetMock = createWidget(); const color = '#0000ff'; @@ -452,112 +443,6 @@ module('Integration: Appointments Collector, adaptivityEnabled = false', baseCon } }); - test('Appointment collector should be painted depend on resource color', function(assert) { - const colors = [ - '#ff0000', - '#ff0000', - '#ff0000', - '#0000ff', - '#0000ff', - '#0000ff' - ]; - - const appointments = [ - { startDate: new Date(2015, 2, 4), text: 'a', endDate: new Date(2015, 2, 4, 0, 30), roomId: 1 }, - { startDate: new Date(2015, 2, 4), text: 'b', endDate: new Date(2015, 2, 4, 0, 30), roomId: 1 }, - - { startDate: new Date(2015, 2, 4), text: 'c', endDate: new Date(2015, 2, 4, 0, 30), roomId: 1 }, - { startDate: new Date(2015, 2, 4), text: 'd', endDate: new Date(2015, 2, 4, 0, 30), roomId: 1 }, - { startDate: new Date(2015, 2, 4), text: 'e', endDate: new Date(2015, 2, 4, 0, 30), roomId: 2 }, - { startDate: new Date(2015, 2, 4), text: 'f', endDate: new Date(2015, 2, 4, 0, 30), roomId: 2 }, - { startDate: new Date(2015, 2, 4), text: 'g', endDate: new Date(2015, 2, 4, 0, 30), roomId: 2 } - ]; - - const scheduler = createInstance({ - currentDate: new Date(2015, 2, 4), - views: ['month'], - width: 840, - height: 490, - currentView: 'month', - firstDayOfWeek: 1, - resources: [ - { - field: 'roomId', - dataSource: [ - { id: 1, color: '#ff0000' }, - { id: 2, color: '#0000ff' } - ] - } - ] - }); - - scheduler.instance.option('dataSource', appointments); - - scheduler.appointments.compact.click(); - scheduler.tooltip.getMarkers().each((index, element) => { - assert.equal(getAppointmentColor($(element)), colors[index], 'Appointment color is OK'); - }); - }); - - test('Appointment collector should be painted depend on resource color when resourses store is asynchronous', function(assert) { - const colors = [ - '#ff0000', - '#ff0000', - '#ff0000', - '#0000ff', - '#0000ff', - '#0000ff' - ]; - - const appointments = [ - { startDate: new Date(2015, 2, 4), text: 'a', endDate: new Date(2015, 2, 4, 0, 30), roomId: 1 }, - { startDate: new Date(2015, 2, 4), text: 'b', endDate: new Date(2015, 2, 4, 0, 30), roomId: 1 }, - - { startDate: new Date(2015, 2, 4), text: 'c', endDate: new Date(2015, 2, 4, 0, 30), roomId: 1 }, - { startDate: new Date(2015, 2, 4), text: 'd', endDate: new Date(2015, 2, 4, 0, 30), roomId: 1 }, - { startDate: new Date(2015, 2, 4), text: 'e', endDate: new Date(2015, 2, 4, 0, 30), roomId: 2 }, - { startDate: new Date(2015, 2, 4), text: 'f', endDate: new Date(2015, 2, 4, 0, 30), roomId: 2 }, - { startDate: new Date(2015, 2, 4), text: 'g', endDate: new Date(2015, 2, 4, 0, 30), roomId: 2 } - ]; - const scheduler = createInstance({ - currentDate: new Date(2015, 2, 4), - views: ['month'], - width: 840, - height: 490, - currentView: 'month', - firstDayOfWeek: 1, - resources: [ - { - field: 'roomId', - allowMultiple: true, - dataSource: new DataSource({ - store: new CustomStore({ - load() { - const d = $.Deferred(); - setTimeout(() => { - d.resolve([ - { id: 1, text: 'Room 1', color: '#ff0000' }, - { id: 2, text: 'Room 2', color: '#0000ff' } - ]); - }, 300); - - return d.promise(); - } - }) - }) - } - ] - }); - - scheduler.instance.option('dataSource', appointments); - this.clock.tick(300); - - scheduler.appointments.compact.click(); - scheduler.tooltip.getMarkers().each((index, element) => { - assert.equal(getAppointmentColor($(element)), colors[index], 'Appointment color is OK'); - }); - }); - test('Collapsed appointments should not be duplicated when items option change (T503748)', function(assert) { const scheduler = createInstance({ views: ['month'], diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/layoutManager.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/layoutManager.tests.js index 8525e301b4ec..48029d5231f4 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/layoutManager.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/layoutManager.tests.js @@ -640,100 +640,6 @@ QUnit.test('More than 3 cloned appointments should be grouped', function(assert) assert.equal(this.scheduler.tooltip.getItemCount(), 8, 'DropDown menu has correct items'); }); -QUnit.test('Grouped appointments schould have correct colors', function(assert) { - const items = []; - let i = 2; - - while(i > 0) { - items.push({ text: i, startDate: new Date(2015, 1, 9), endDate: new Date(2015, 1, 9, 1), roomId: 1 }); - i--; - } - i = 10; - while(i > 0) { - items.push({ text: i, startDate: new Date(2015, 1, 9, 3), endDate: new Date(2015, 1, 9, 4), roomId: 2 }); - i--; - } - - this.createInstance( - { - currentDate: new Date(2015, 1, 9), - currentView: 'month', - height: 500, - dataSource: items, - maxAppointmentsPerCell: 2, - resources: [ - { - field: 'roomId', - allowMultiple: true, - dataSource: [ - { id: 1, text: 'Room 1', color: '#ff0000' }, - { id: 2, text: 'Room 2', color: '#0000ff' } - ] - } - ] - } - ); - - const $appointment = $(this.instance.$element().find('.dx-scheduler-appointment')); - assert.equal($appointment.length, 2, 'Cloned appointments are grouped'); - - const $dropDownMenu = $(this.instance.$element()).find('.dx-scheduler-appointment-collector'); - - assert.equal(new Color($dropDownMenu.css('backgroundColor')).toHex(), '#0000ff', 'ddAppointment is rendered'); -}); - -QUnit.test('Grouped appointments schould have correct colors when resourses store is asynchronous', function(assert) { - const items = []; - let i = 2; - - while(i > 0) { - items.push({ text: i, startDate: new Date(2015, 1, 9), endDate: new Date(2015, 1, 9, 1), roomId: 1 }); - i--; - } - i = 10; - while(i > 0) { - items.push({ text: i, startDate: new Date(2015, 1, 9, 3), endDate: new Date(2015, 1, 9, 4), roomId: 2 }); - i--; - } - - this.createInstance( - { - currentDate: new Date(2015, 1, 9), - currentView: 'month', - height: 500, - dataSource: items, - maxAppointmentsPerCell: 2, - resources: [ - { - field: 'roomId', - allowMultiple: true, - dataSource: new CustomStore({ - load: function() { - const d = $.Deferred(); - setTimeout(function() { - d.resolve([ - { id: 1, text: 'Room 1', color: '#ff0000' }, - { id: 2, text: 'Room 2', color: '#0000ff' } - ]); - }, 300); - - return d.promise(); - } - }) - } - ] - } - ); - - this.clock.tick(300); - const $appointment = $(this.instance.$element().find('.dx-scheduler-appointment')); - assert.equal($appointment.length, 2, 'Cloned appointments are grouped'); - - const $dropDownMenu = $(this.instance.$element()).find('.dx-scheduler-appointment-collector'); - this.clock.tick(300); - assert.equal(new Color($dropDownMenu.css('backgroundColor')).toHex(), '#0000ff', 'ddAppointment is rendered'); -}); - QUnit.test('Grouped appointments should be reinitialized if datasource is changed', function(assert) { const items = []; let i = 7; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js index 3b0e6fc0b6ae..0a39b88ae0e1 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js @@ -1,253 +1,9 @@ -import $ from 'jquery'; - -import 'ui/chat'; - -QUnit.testStart(function() { - const markup = '
'; - - $('#qunit-fixture').html(markup); -}); - -const CHAT_CLASS = 'dx-chat'; -const CHAT_HEADER_CLASS = 'dx-chat-header'; -const CHAT_HEADER_TEXT_CLASS = 'dx-chat-header-text'; -const CHAT_MESSAGE_BOX_CLASS = 'dx-chat-message-box'; -const CHAT_MESSAGE_BOX_TEXTAREA_CLASS = 'dx-chat-message-box-text-area'; -const CHAT_MESSAGE_BOX_BUTTON_CLASS = 'dx-chat-message-box-button'; -const CHAT_MESSAGE_LIST_CLASS = 'dx-chat-message-list'; -const CHAT_MESSAGE_LIST_CONTENT_CLASS = 'dx-chat-message-list-content'; -const CHAT_MESSAGE_GROUP_CLASS = 'dx-chat-message-group'; -const CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS = 'dx-chat-message-group-alignment-start'; -const CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS = 'dx-chat-message-group-alignment-end'; -const CHAT_MESSAGE_GROUP_INFORMATION_CLASS = 'dx-chat-message-group-information'; -const CHAT_MESSAGE_TIME_CLASS = 'dx-chat-message-time'; -const CHAT_MESSAGE_NAME_CLASS = 'dx-chat-message-name'; -const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; -const CHAT_MESSAGE_BUBBLE_FIRST_CLASS = 'dx-chat-message-bubble-first'; -const CHAT_MESSAGE_BUBBLE_LAST_CLASS = 'dx-chat-message-bubble-last'; -const CHAT_MESSAGE_AVATAR_CLASS = 'dx-chat-message-avatar'; -const CHAT_MESSAGE_AVATAR_INITIALS_CLASS = 'dx-chat-message-avatar-initials'; - -const TEXTAREA_CLASS = 'dx-textarea'; -const BUTTON_CLASS = 'dx-button'; - -const MOCK_COMPANION_USER_ID = 'COMPANION_USER_ID'; -const MOCK_CURRENT_USER_ID = 'CURRENT_USER_ID'; - -const moduleConfig = { - beforeEach: function() { - const init = (options = {}) => { - this.$element = $('#chat').dxChat(options); - this.instance = this.$element.dxChat('instance'); - }; - - const userFirst = { - id: MOCK_COMPANION_USER_ID, - name: 'First', - }; - - const userSecond = { - id: MOCK_CURRENT_USER_ID, - name: 'Second', - }; - - const now = Date.now(); - - const messages = [ - { - timestamp: String(now), - author: userFirst, - text: 'userFirst', - }, - { - timestamp: String(now), - author: userFirst, - text: 'userFirst', - }, - { - timestamp: String(now), - author: userFirst, - text: 'userFirst', - }, - { - timestamp: String(now), - author: userSecond, - text: 'userSecond', - }, - { - timestamp: String(now), - author: userSecond, - text: 'userSecond', - }, - { - timestamp: String(now), - author: userSecond, - text: 'userSecond', - }, - { - timestamp: String(now), - author: userFirst, - text: 'userFirst', - }, - ]; - - const options = { - user: userSecond, - items: messages, - }; - - init(options); - } -}; - -QUnit.module('Render', moduleConfig, () => { - QUnit.test('Header should be rendered', function(assert) { - const $header = this.$element.find(`.${CHAT_HEADER_CLASS}`); - - assert.strictEqual($header.length, 1); - }); - - QUnit.test('Header text element should be rendered', function(assert) { - const $headerText = this.$element.find(`.${CHAT_HEADER_TEXT_CLASS}`); - - assert.strictEqual($headerText.length, 1); - }); - - QUnit.test('Message box should be rendered', function(assert) { - const $messageBox = this.$element.find(`.${CHAT_MESSAGE_BOX_CLASS}`); - - assert.strictEqual($messageBox.length, 1); - }); - - QUnit.test('Message box textarea should be rendered', function(assert) { - const $textArea = this.$element.find(`.${TEXTAREA_CLASS}`); - - assert.strictEqual($textArea.length, 1); - }); - - QUnit.test('Message box button should be rendered', function(assert) { - const $button = this.$element.find(`.${BUTTON_CLASS}`); - - assert.strictEqual($button.length, 1); - }); - - QUnit.test('Message list should be rendered', function(assert) { - const $messageList = this.$element.find(`.${CHAT_MESSAGE_LIST_CLASS}`); - - assert.strictEqual($messageList.length, 1); - }); - - QUnit.test('Message list content should be rendered', function(assert) { - const $messageListContent = this.$element.find(`.${CHAT_MESSAGE_LIST_CONTENT_CLASS}`); - - assert.strictEqual($messageListContent.length, 1); - }); - - QUnit.test('Message list content should be rendered if items is empty', function(assert) { - this.instance.option({ items: [] }); - const $messageListContent = this.$element.find(`.${CHAT_MESSAGE_LIST_CONTENT_CLASS}`); - - assert.strictEqual($messageListContent.length, 1); - }); - - QUnit.test('Message groups should be rendered', function(assert) { - const $messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); - - assert.strictEqual($messageGroups.length, 3); - }); - - QUnit.test('Avatar should be rendered in first message group', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $avatar = $messageGroup.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`); - - assert.strictEqual($avatar.length, 1); - }); - - QUnit.test('Avatar initials element should be rendered in avatar', function(assert) { - const $avatar = this.$element.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`).eq(0); - const $initials = $avatar.find(`.${CHAT_MESSAGE_AVATAR_INITIALS_CLASS}`); - - assert.strictEqual($initials.length, 1); - }); - - QUnit.test('Avatar should not be rendered in second message group', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(1); - const $avatar = $messageGroup.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`); - - assert.strictEqual($avatar.length, 0); - }); - - QUnit.test('Message group information should be rendered', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $information = $messageGroup.find(`.${CHAT_MESSAGE_GROUP_INFORMATION_CLASS}`); - - assert.strictEqual($information.length, 1); - }); - - QUnit.test('Message group time should be rendered', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $time = $messageGroup.find(`.${CHAT_MESSAGE_TIME_CLASS}`); - - assert.strictEqual($time.length, 1); - }); - - QUnit.test('Message group user name should be rendered', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $name = $messageGroup.find(`.${CHAT_MESSAGE_NAME_CLASS}`); - - assert.strictEqual($name.length, 1); - }); - - QUnit.test('Message bubble should be rendered in message group', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $bubbles = $messageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); - - assert.strictEqual($bubbles.length, 3); - }); -}); - -QUnit.module('Classes', moduleConfig, () => { - QUnit.test(`Chat should have ${CHAT_CLASS} class`, function(assert) { - assert.strictEqual(this.$element.hasClass(CHAT_CLASS), true); - }); - - QUnit.test(`Message box textarea should have ${CHAT_MESSAGE_BOX_TEXTAREA_CLASS} class`, function(assert) { - const $textArea = this.$element.find(`.${TEXTAREA_CLASS}`); - - assert.strictEqual($textArea.hasClass(CHAT_MESSAGE_BOX_TEXTAREA_CLASS), true); - }); - - QUnit.test(`Message box button should have ${CHAT_MESSAGE_BOX_BUTTON_CLASS} class`, function(assert) { - const $button = this.$element.find(`.${BUTTON_CLASS}`); - - assert.strictEqual($button.hasClass(CHAT_MESSAGE_BOX_BUTTON_CLASS), true); - }); - - QUnit.test(`First message group should have ${CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS} class`, function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - - assert.strictEqual($messageGroup.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS), true); - }); - - QUnit.test(`Second message group should have ${CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS} class`, function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(1); - - assert.strictEqual($messageGroup.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS), true); - }); - - QUnit.test('Message bubble should have correct classes', function(assert) { - const $firstMessageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $bubbles = $firstMessageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); - - assert.strictEqual($bubbles.eq(0).hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), true); - assert.strictEqual($bubbles.eq(1).hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), false); - assert.strictEqual($bubbles.eq(1).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), false); - assert.strictEqual($bubbles.eq(2).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), true); - - const $lastMessageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(2); - const $bubble = $lastMessageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); - - assert.strictEqual($bubble.hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), true); - assert.strictEqual($bubble.hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), true); - }); -}); +import 'generic_light.css!'; + +import './chatParts/header.markup.tests.js'; +import './chatParts/avatar.markup.tests.js'; +import './chatParts/messageBox.markup.tests.js'; +import './chatParts/messageBubble.markup.tests.js'; +import './chatParts/messageGroup.markup.tests.js'; +import './chatParts/messageList.markup.tests.js'; +import './chatParts/chat.markup.tests.js'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js index 7d4f99fd4425..3ece9680483e 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js @@ -1,579 +1,9 @@ -import $ from 'jquery'; -import Chat from 'ui/chat'; -import fx from 'animation/fx'; -import keyboardMock from '../../helpers/keyboardMock.js'; -import { isRenderer } from 'core/utils/type'; -import config from 'core/config'; - import 'generic_light.css!'; -const CHAT_HEADER_TEXT_CLASS = 'dx-chat-header-text'; -const CHAT_MESSAGE_GROUP_CLASS = 'dx-chat-message-group'; -const CHAT_MESSAGE_TIME_CLASS = 'dx-chat-message-time'; -const CHAT_MESSAGE_NAME_CLASS = 'dx-chat-message-name'; -const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; -const CHAT_MESSAGE_BUBBLE_LAST_CLASS = 'dx-chat-message-bubble-last'; -const CHAT_MESSAGE_AVATAR_INITIALS_CLASS = 'dx-chat-message-avatar-initials'; -const CHAT_MESSAGE_BOX_BUTTON_CLASS = 'dx-chat-message-box-button'; -const CHAT_MESSAGE_LIST_CLASS = 'dx-chat-message-list'; - -const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input'; -const SCROLLABLE_CLASS = 'dx-scrollable'; - -const MOCK_CHAT_HEADER_TEXT = 'Chat title'; -const MOCK_COMPANION_USER_ID = 'COMPANION_USER_ID'; -const MOCK_CURRENT_USER_ID = 'CURRENT_USER_ID'; -const NOW = '1721747399083'; -const userFirst = { - id: MOCK_COMPANION_USER_ID, - name: 'First', -}; -const userSecond = { - id: MOCK_CURRENT_USER_ID, - name: 'Second', -}; - -const getDateTimeString = (timestamp) => { - const options = { hour: '2-digit', minute: '2-digit', hour12: false }; - const dateTime = new Date(Number(timestamp)); - const dateTimeString = dateTime.toLocaleTimeString(undefined, options); - - return dateTimeString; -}; - -const generateMessages = (length) => { - const messages = Array.from({ length }, (_, i) => { - const item = { - timestamp: NOW, - author: i % 4 === 0 ? userFirst : userSecond, - text: String(Math.random()), - }; - - return item; - }); - - return messages; -}; - -QUnit.testStart(() => { - const markup = '
'; - - $('#qunit-fixture').html(markup); -}); - -const moduleConfig = { - beforeEach: function() { - fx.off = true; - - const init = (options = {}) => { - this.$element = $('#chat').dxChat(options); - this.instance = this.$element.dxChat('instance'); - }; - - this.reinit = (options) => { - this.instance.dispose(); - - init(options); - }; - - const messages = [ - { - timestamp: NOW, - author: userFirst, - text: 'userFirst', - }, - { - timestamp: NOW, - author: userFirst, - text: 'userFirst', - }, - { - timestamp: NOW, - author: userSecond, - text: 'userSecond', - }, - ]; - - const options = { - title: MOCK_CHAT_HEADER_TEXT, - items: messages, - }; - - init(options); - }, - afterEach: function() { - fx.off = false; - } -}; - -QUnit.module('Chat initialization', moduleConfig, () => { - QUnit.test('Chat should be initialized with correct type', function(assert) { - assert.ok(this.instance instanceof Chat); - }); - - QUnit.test('currentUserId in message list should be equal chat user id', function(assert) { - const messageList = this.instance._messageList; - - const chatUserId = this.instance.option('user.id'); - const messageListUserId = messageList.option('currentUserId'); - - assert.strictEqual(chatUserId, messageListUserId); - }); - - QUnit.test('currentUserId in message list should be changed when user has been changed in runtime', function(assert) { - const newId = 'new id'; - - this.instance.option({ user: { id: newId } }); - - const { currentUserId } = this.instance._messageList.option(); - - assert.strictEqual(currentUserId, newId); - }); - - QUnit.test('items in message list should be changed when items has been changed in runtime', function(assert) { - const newItems = []; - - this.instance.option({ items: newItems }); - - const { items } = this.instance._messageList.option(); - - assert.strictEqual(items, newItems); - }); - - QUnit.test('Message list should run invalidate after changing user in runtime', function(assert) { - const invalidateStub = sinon.stub(this.instance._messageList, '_invalidate'); - - this.instance.option({ user: {} }); - - assert.strictEqual(invalidateStub.callCount, 1); - }); - - QUnit.test('Message list should run invalidate after changing items in runtime', function(assert) { - const invalidateStub = sinon.stub(this.instance._messageList, '_invalidate'); - - this.instance.option({ items: [] }); - - assert.strictEqual(invalidateStub.callCount, 1); - }); -}); - -QUnit.module('Header', moduleConfig, () => { - QUnit.test('Header text element should have correct text', function(assert) { - const $header = this.$element.find(`.${CHAT_HEADER_TEXT_CLASS}`); - - assert.strictEqual($header.text(), MOCK_CHAT_HEADER_TEXT); - }); - - QUnit.test('Header text element should have correct text after runtime change', function(assert) { - this.instance.option({ title: 'new title' }); - - const $header = this.$element.find(`.${CHAT_HEADER_TEXT_CLASS}`); - - assert.strictEqual($header.text(), 'new title'); - }); -}); - -QUnit.module('Message group', moduleConfig, () => { - QUnit.test('Message groups should has correct bubble elements count', function(assert) { - const $firstMessageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $secondMessageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(1); - - const $firstMessageGroupBubbles = $firstMessageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); - const $secondMessageGroupBubbles = $secondMessageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); - - assert.strictEqual($firstMessageGroupBubbles.length, 2); - assert.strictEqual($secondMessageGroupBubbles.length, 1); - }); - - QUnit.test('Avatar should have correct text', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $avatarText = $messageGroup.find(`.${CHAT_MESSAGE_AVATAR_INITIALS_CLASS}`); - - assert.strictEqual($avatarText.text(), 'F'); - }); - - QUnit.test('Message group time should be correct', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $time = $messageGroup.find(`.${CHAT_MESSAGE_TIME_CLASS}`); - - assert.strictEqual($time.text(), getDateTimeString(NOW)); - }); - - QUnit.test('Message group user name should be correct', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $name = $messageGroup.find(`.${CHAT_MESSAGE_NAME_CLASS}`); - - assert.strictEqual($name.text(), 'First'); - }); - - QUnit.test('Message bubble should have correct text', function(assert) { - const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); - const $bubble = $messageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`).eq(0); - - assert.strictEqual($bubble.text(), 'userFirst'); - }); -}); - -QUnit.module('renderMessage', moduleConfig, () => { - QUnit.test('Chat items should be updated after renderMessage has been called', function(assert) { - const author = { - id: MOCK_CURRENT_USER_ID, - }; - - const newMessage = { - author, - timestamp: NOW, - text: 'NEW MESSAGE', - }; - - this.instance.renderMessage(newMessage, author); - - const { items } = this.instance.option(); - const lastItem = items[items.length - 1]; - - assert.strictEqual(lastItem, newMessage); - }); - - QUnit.test('Message List items should be updated after renderMessage has been called', function(assert) { - const author = { - id: MOCK_CURRENT_USER_ID, - }; - - const newMessage = { - author, - timestamp: NOW, - text: 'NEW MESSAGE', - }; - - this.instance.renderMessage(newMessage, author); - - const { items } = this.instance._messageList.option(); - const lastItem = items[items.length - 1]; - - assert.strictEqual(lastItem, newMessage); - }); - - QUnit.test('Last Message Group items should be updated if its user ids are equal with new message', function(assert) { - const author = { - id: MOCK_CURRENT_USER_ID, - }; - - const newMessage = { - author, - timestamp: NOW, - text: 'NEW MESSAGE', - }; - - const messageGroups = this.instance._messageList._messageGroups; - const lastMessageGroup = messageGroups[messageGroups.length - 1]; - const lastMessageGroupElement = lastMessageGroup.element(); - - assert.strictEqual($(lastMessageGroupElement).find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`).length, 1); - - this.instance.renderMessage(newMessage, author); - - const { items: messages } = lastMessageGroup.option(); - const lastMessage = messages[messages.length - 1]; - - assert.strictEqual(lastMessage, newMessage); - assert.strictEqual($(lastMessageGroupElement).find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`).length, 2); - }); - - QUnit.test('Message Group should be created if its user ids are not equal with new message', function(assert) { - const author = { - id: MOCK_COMPANION_USER_ID, - }; - - const newMessage = { - author, - timestamp: NOW, - text: 'NEW MESSAGE', - }; - - const getMessageGroupElements = () => this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); - const messageGroups = this.instance._messageList._messageGroups; - - assert.strictEqual(messageGroups.length, 2); - assert.strictEqual(getMessageGroupElements().length, 2); - - this.instance.renderMessage(newMessage, author); - - assert.strictEqual(messageGroups.length, 3); - assert.strictEqual(getMessageGroupElements().length, 3); - }); - - QUnit.test('Message Group should be created if items are empty', function(assert) { - this.instance.option({ items: [] }); - - const author = { - id: MOCK_CURRENT_USER_ID, - }; - - const newMessage = { - author, - timestamp: NOW, - text: 'NEW MESSAGE', - }; - - const getMessageGroups = () => this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); - - assert.strictEqual(getMessageGroups().length, 0); - - this.instance.renderMessage(newMessage, author); - - assert.strictEqual(getMessageGroups().length, 1); - }); - - QUnit.test('Last class should be deleted from last bubble after renderMessage', function(assert) { - const author = { - id: MOCK_CURRENT_USER_ID, - }; - - const newMessage = { - author, - timestamp: NOW, - text: 'NEW MESSAGE', - }; - - const getLastMessageGroupBubbles = () => { - const messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); - const lastMessageGroup = messageGroups[messageGroups.length - 1]; - const $bubbles = $(lastMessageGroup).find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); - - return $bubbles; - }; - - let $bubbles = getLastMessageGroupBubbles(); - assert.strictEqual($bubbles.eq($bubbles.length - 1).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), true); - - this.instance.renderMessage(newMessage, author); - - $bubbles = getLastMessageGroupBubbles(); - assert.strictEqual($bubbles.eq($bubbles.length - 2).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), false); - }); - - - QUnit.test('New bubble should be rendered after renderMessage with correct text', function(assert) { - const text = 'NEW MESSAGE'; - const author = { id: MOCK_CURRENT_USER_ID }; - const newMessage = { - author, - timestamp: NOW, - text, - }; - - this.instance.renderMessage(newMessage, author); - - const messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); - const lastMessageGroup = messageGroups[messageGroups.length - 1]; - const $bubbles = $(lastMessageGroup).find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); - const lastBubble = $bubbles[$bubbles.length - 1]; - - assert.strictEqual($(lastBubble).text(), text); - }); -}); - -QUnit.module('onMessageSend', moduleConfig, () => { - QUnit.test('onMessageSend should be called when the send button was clicked if there is text', function(assert) { - const onMessageSend = sinon.spy(); - - const $element = $('#chat').dxChat({ onMessageSend }); - - const $textArea = $element.find(`.${TEXTEDITOR_INPUT_CLASS}`); - const $button = $element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); - - keyboardMock($textArea).focus().type('new text message'); - - $button.trigger('dxclick'); - - assert.strictEqual(onMessageSend.callCount, 1); - }); - - QUnit.test('New message should be created after clicking the send button if there is text', function(assert) { - const text = 'new text message'; - - const $textArea = this.$element.find(`.${TEXTEDITOR_INPUT_CLASS}`); - const $button = this.$element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); - - keyboardMock($textArea).focus().type(text); - - $button.trigger('dxclick'); - - const $bubbles = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); - const bubble = $bubbles[$bubbles.length - 1]; - - assert.strictEqual($(bubble).text(), text); - }); - - QUnit.test('TextArea text should be empty after clicking the send button if there is text', function(assert) { - const text = 'new text message'; - - const $textArea = this.$element.find(`.${TEXTEDITOR_INPUT_CLASS}`); - const $button = this.$element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); - - keyboardMock($textArea).focus().type(text); - - $button.trigger('dxclick'); - - assert.strictEqual(this.$element.find(`.${TEXTEDITOR_INPUT_CLASS}`).get(0).value, ''); - }); - - QUnit.test('onMessageSend should be called after clicking the send button if there is text', function(assert) { - const onMessageSend = sinon.spy(); - - this.instance.option({ onMessageSend }); - - const text = 'new text message'; - - const $textArea = this.$element.find(`.${TEXTEDITOR_INPUT_CLASS}`); - const $button = this.$element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); - - keyboardMock($textArea).focus().type(text); - - $button.trigger('dxclick'); - - assert.strictEqual(onMessageSend.callCount, 1); - }); - - QUnit.test('onMessageSend should be get correct object after clicking the send button if there is text', function(assert) { - assert.expect(5); - - const text = 'new text message'; - - const $textArea = this.$element.find(`.${TEXTEDITOR_INPUT_CLASS}`); - const $button = this.$element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); - - this.instance.option({ - onMessageSend: ({ component, element, event, message }) => { - assert.strictEqual(component, this.instance, 'component field is correct'); - assert.strictEqual(isRenderer(element), !!config().useJQuery, 'element is correct'); - assert.strictEqual($(element).is(this.$element), true, 'element field is correct'); - assert.strictEqual(event.target, $button.get(0), 'event field is correct'); - assert.strictEqual(message.text, text, 'message field is correct'); - }, - }); - - keyboardMock($textArea).focus().type(text); - - $button.trigger('dxclick'); - }); - - QUnit.test('New message should be correct after clicking the send button if there is text', function(assert) { - assert.expect(3); - - const text = 'new text message'; - - const $textArea = this.$element.find(`.${TEXTEDITOR_INPUT_CLASS}`); - const $button = this.$element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); - - this.instance.option({ - onMessageSend: ({ message }) => { - const { author, text: messageText } = message; - - assert.strictEqual(author, this.instance.option('user'), 'author field is correct'); - // eslint-disable-next-line no-prototype-builtins - assert.strictEqual(message.hasOwnProperty('timestamp'), true, 'timestamp field is set'); - assert.strictEqual(messageText, text, 'text field is correct'); - }, - }); - - keyboardMock($textArea).focus().type(text); - - $button.trigger('dxclick'); - }); - - QUnit.test('New message should not be created after clicking the send button if there is no text', function(assert) { - const $button = this.$element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); - - assert.strictEqual(this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`).length, 3); - - $button.trigger('dxclick'); - - assert.strictEqual(this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`).length, 3); - }); - - QUnit.test('onMessageSend should not be called after clicking the send button if there is no text', function(assert) { - const onMessageSend = sinon.spy(); - - this.instance.option({ onMessageSend }); - - const $button = this.$element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); - - $button.trigger('dxclick'); - - assert.strictEqual(onMessageSend.callCount, 0); - }); -}); - -QUnit.module('Default options', moduleConfig, () => { - QUnit.test('There is an user id by default if user has not been set', function(assert) { - const instance = $('#chat').dxChat().dxChat('instance'); - - const { user } = instance.option(); - - // eslint-disable-next-line no-prototype-builtins - assert.strictEqual(user.hasOwnProperty('id'), true); - }); - - QUnit.test('User id should be generate as a string if user has not been set', function(assert) { - const instance = $('#chat').dxChat().dxChat('instance'); - - assert.strictEqual(typeof instance.option('user.id') === 'string', true); - }); -}); - -QUnit.module('Scrolling', moduleConfig, () => { - QUnit.test('Scrollable should be rendered into Message List', function(assert) { - const $messageList = this.$element.find(`.${CHAT_MESSAGE_LIST_CLASS}`); - const $scrollable = $messageList.children(`.${SCROLLABLE_CLASS}`); - - assert.strictEqual($scrollable.length, 1); - }); - - QUnit.test('Scrollable should be scrolled to last message group after init', function(assert) { - this.reinit({ items: generateMessages(31) }); - - const scrollable = this.$element.find(`.${SCROLLABLE_CLASS}`).dxScrollable('instance'); - const scrollTop = scrollable.scrollTop(); - - assert.strictEqual(scrollTop !== 0, true); - }); - - QUnit.test('Scrollable should be scrolled to last message group if items canged in runtime', function(assert) { - this.instance.option({ items: generateMessages(31) }); - - const scrollable = this.$element.find(`.${SCROLLABLE_CLASS}`).dxScrollable('instance'); - const scrollTop = scrollable.scrollTop(); - - assert.strictEqual(scrollTop !== 0, true); - }); - - [MOCK_CURRENT_USER_ID, MOCK_COMPANION_USER_ID].forEach(id => { - const isCurrentUser = id === MOCK_CURRENT_USER_ID; - const textName = `Scrollable should be scrolled to last message group after render ${isCurrentUser ? 'current user' : 'companion'} message`; - - QUnit.test(textName, function(assert) { - assert.expect(1); - - this.reinit({ items: generateMessages(31) }); - - const author = { id }; - const newMessage = { - author, - timestamp: NOW, - text: 'NEW MESSAGE', - }; - - const scrollable = this.$element.find(`.${SCROLLABLE_CLASS}`).dxScrollable('instance'); - - scrollable.scrollToElement = ($item) => { - const messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); - const lastMessageGroup = messageGroups[messageGroups.length - 1]; - - assert.strictEqual($item, lastMessageGroup); - }; - - this.instance.renderMessage(newMessage, author); - }); - }); -}); +import './chatParts/header.tests.js'; +import './chatParts/avatar.tests.js'; +import './chatParts/messageBox.tests.js'; +import './chatParts/messageBubble.tests.js'; +import './chatParts/messageGroup.tests.js'; +import './chatParts/messageList.tests.js'; +import './chatParts/chat.tests.js'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/avatar.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/avatar.markup.tests.js new file mode 100644 index 000000000000..8bbbc99830ad --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/avatar.markup.tests.js @@ -0,0 +1,36 @@ +import $ from 'jquery'; + +import ChatAvatar from '__internal/ui/chat/chat_avatar'; + +const CHAT_MESSAGE_AVATAR_CLASS = 'dx-chat-message-avatar'; +const CHAT_MESSAGE_AVATAR_INITIALS_CLASS = 'dx-chat-message-avatar-initials'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new ChatAvatar($('#avatar'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('Avatar classes', moduleConfig, () => { + QUnit.test('root element should have correct class', function(assert) { + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_AVATAR_CLASS), true); + }); + + QUnit.test('text element should have correct class', function(assert) { + assert.strictEqual(this.$element.children().first().hasClass(CHAT_MESSAGE_AVATAR_INITIALS_CLASS), true); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/avatar.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/avatar.tests.js new file mode 100644 index 000000000000..f3eecbf5c1c2 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/avatar.tests.js @@ -0,0 +1,67 @@ +import $ from 'jquery'; + +import ChatAvatar from '__internal/ui/chat/chat_avatar'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new ChatAvatar($('#avatar'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('ChatAvatar', moduleConfig, () => { + QUnit.module('Render', () => { + QUnit.test('should be initialized with correct type', function(assert) { + assert.ok(this.instance instanceof ChatAvatar); + }); + + QUnit.test('should be rendered with empty value by default', function(assert) { + assert.strictEqual(this.$element.text(), ''); + }); + }); + + QUnit.module('Options', () => { + QUnit.test('should be rendered with correct initials according passed name option value', function(assert) { + this.reinit({ name: 'Chat title' }); + + assert.strictEqual(this.$element.text(), 'C'); + }); + + QUnit.test('name option should be updatable at runtime', function(assert) { + this.instance.option('name', 'New Value'); + + assert.strictEqual(this.$element.text(), 'N'); + }); + + [ + { name: 888, expectedInitials: '8' }, + { name: undefined, expectedInitials: '' }, + { name: null, expectedInitials: '' }, + // TODO: consider scenarios + // { name: ' New Name', expectedInitials: 'N' }, + // { name: NaN, expectedInitials: '' }, + // { name: Infinity, expectedInitials: '' }, + // { name: -Infinity, expectedInitials: '' }, + // { name: { firstName: 'name' }, expectedInitials: '' } + ].forEach(({ name, expectedInitials }) => { + QUnit.test(`name option is ${name}`, function(assert) { + this.reinit({ name }); + + assert.strictEqual(this.$element.text(), expectedInitials); + }); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.markup.tests.js new file mode 100644 index 000000000000..a77d38962889 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.markup.tests.js @@ -0,0 +1,57 @@ +import $ from 'jquery'; + +import Chat from 'ui/chat'; + +const CHAT_CLASS = 'dx-chat'; +const CHAT_HEADER_CLASS = 'dx-chat-header'; +const CHAT_MESSAGE_BOX_CLASS = 'dx-chat-message-box'; +const CHAT_MESSAGE_LIST_CLASS = 'dx-chat-message-list'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new Chat($('#chat'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('Chat', moduleConfig, () => { + QUnit.module('Render', () => { + QUnit.test('Header should be rendered', function(assert) { + const $header = this.$element.find(`.${CHAT_HEADER_CLASS}`); + + assert.strictEqual($header.length, 1); + }); + + QUnit.test('Message list should be rendered', function(assert) { + const $messageList = this.$element.find(`.${CHAT_MESSAGE_LIST_CLASS}`); + + assert.strictEqual($messageList.length, 1); + }); + + QUnit.test('Message box should be rendered', function(assert) { + const $messageBox = this.$element.find(`.${CHAT_MESSAGE_BOX_CLASS}`); + + assert.strictEqual($messageBox.length, 1); + }); + }); + + QUnit.module('Classes', () => { + QUnit.test(`root element should have ${CHAT_CLASS} class`, function(assert) { + assert.strictEqual(this.$element.hasClass(CHAT_CLASS), true); + }); + }); +}); + diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js new file mode 100644 index 000000000000..a33258e6e858 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js @@ -0,0 +1,399 @@ +import $ from 'jquery'; + +import Chat from 'ui/chat'; +import MessageList from '__internal/ui/chat/chat_message_list'; +import MessageBox from '__internal/ui/chat/chat_message_box'; +import keyboardMock from '../../../helpers/keyboardMock.js'; +import { isRenderer } from 'core/utils/type'; +import config from 'core/config'; + +const CHAT_HEADER_TEXT_CLASS = 'dx-chat-header-text'; +const CHAT_MESSAGE_GROUP_CLASS = 'dx-chat-message-group'; +const CHAT_MESSAGE_LIST_CLASS = 'dx-chat-message-list'; +const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; +const CHAT_MESSAGE_BOX_CLASS = 'dx-chat-message-box'; +const CHAT_MESSAGE_BOX_BUTTON_CLASS = 'dx-chat-message-box-button'; +const CHAT_MESSAGE_BOX_TEXTAREA_CLASS = 'dx-chat-message-box-text-area'; + +const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input'; + +const MOCK_CHAT_HEADER_TEXT = 'Chat title'; + +export const MOCK_COMPANION_USER_ID = 'COMPANION_USER_ID'; +export const MOCK_CURRENT_USER_ID = 'CURRENT_USER_ID'; +export const NOW = '1721747399083'; + +export const userFirst = { + id: MOCK_COMPANION_USER_ID, + name: 'First', +}; + +export const userSecond = { + id: MOCK_CURRENT_USER_ID, + name: 'Second', +}; + +export const generateMessages = (length) => { + const messages = Array.from({ length }, (_, i) => { + const item = { + timestamp: NOW, + author: i % 4 === 0 ? userFirst : userSecond, + text: String(Math.random()), + }; + + return item; + }); + + return messages; +}; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new Chat($('#chat'), options); + this.$element = $(this.instance.$element()); + + this.$textArea = this.$element.find(`.${CHAT_MESSAGE_BOX_TEXTAREA_CLASS}`); + this.$input = this.$element.find(`.${TEXTEDITOR_INPUT_CLASS}`); + + this.$sendButton = this.$element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('Chat', moduleConfig, () => { + QUnit.module('Render', () => { + QUnit.test('should be initialized with correct type', function(assert) { + assert.ok(this.instance instanceof Chat); + }); + }); + + QUnit.module('Default options', () => { + QUnit.test('user should be set to an object with generated id if property is not passed', function(assert) { + const { user } = this.instance.option(); + + // eslint-disable-next-line no-prototype-builtins + assert.strictEqual(user.hasOwnProperty('id'), true); + }); + + QUnit.test('User id should be generated as a string if user has not been set', function(assert) { + assert.strictEqual(typeof this.instance.option('user.id') === 'string', true); + }); + }); + + QUnit.module('Header integration', () => { + QUnit.test('Header text element should have correct text', function(assert) { + this.reinit({ + title: MOCK_CHAT_HEADER_TEXT + }); + + const $header = this.$element.find(`.${CHAT_HEADER_TEXT_CLASS}`); + + assert.strictEqual($header.text(), MOCK_CHAT_HEADER_TEXT); + }); + + QUnit.test('Header text element should have correct text after runtime change', function(assert) { + this.instance.option({ title: 'new title' }); + + const $header = this.$element.find(`.${CHAT_HEADER_TEXT_CLASS}`); + + assert.strictEqual($header.text(), 'new title'); + }); + }); + + QUnit.module('MessageList integration', () => { + QUnit.test('passed currentUserId should be equal generated chat.user.id', function(assert) { + const messageList = MessageList.getInstance(this.$element.find(`.${CHAT_MESSAGE_LIST_CLASS}`)); + + const expectedOptions = { + items: [], + currentUserId: this.instance.option('user.id'), + }; + + Object.entries(expectedOptions).forEach(([key, value]) => { + assert.deepEqual(value, messageList.option(key), `${key} value is correct`); + }); + }); + + QUnit.test('passed currentUserId should be equal chat.user.id', function(assert) { + const messages = [{}, {}]; + + this.reinit({ + user: { + id: 'UserID' + }, + items: messages + }); + + const messageList = MessageList.getInstance(this.$element.find(`.${CHAT_MESSAGE_LIST_CLASS}`)); + + const expectedOptions = { + items: messages, + currentUserId: 'UserID', + }; + + Object.entries(expectedOptions).forEach(([key, value]) => { + assert.deepEqual(value, messageList.option(key), `${key} value is correct`); + }); + }); + + QUnit.test('currentUserId should be updated when user has been changed in runtime', function(assert) { + const newUserID = 'newUserID'; + + this.instance.option({ user: { id: newUserID } }); + + const messageList = MessageList.getInstance(this.$element.find(`.${CHAT_MESSAGE_LIST_CLASS}`)); + + assert.deepEqual(messageList.option('currentUserId'), newUserID, 'currentUserId value is updated'); + }); + + QUnit.test('items should be passed to messageList after update', function(assert) { + const newItems = [{ author: { name: 'Mike' } }, { author: { name: 'John' } }]; + + this.instance.option('items', newItems); + + const messageList = MessageList.getInstance(this.$element.find(`.${CHAT_MESSAGE_LIST_CLASS}`)); + + assert.deepEqual(messageList.option('items'), newItems, 'items value is updated'); + }); + }); + + QUnit.module('Events', () => { + QUnit.module('onMessageSend', moduleConfig, () => { + QUnit.test('should be called when the send button was clicked', function(assert) { + const onMessageSend = sinon.spy(); + + this.reinit({ onMessageSend }); + + keyboardMock(this.$input) + .focus() + .type('new text message'); + + this.$sendButton.trigger('dxclick'); + + assert.strictEqual(onMessageSend.callCount, 1); + }); + + QUnit.test('should get correct arguments after clicking the send button', function(assert) { + assert.expect(6); + + const text = 'new text message'; + + this.instance.option({ + onMessageSend: ({ component, element, event, message }) => { + assert.strictEqual(component, this.instance, 'component field is correct'); + assert.strictEqual(isRenderer(element), !!config().useJQuery, 'element is correct'); + assert.strictEqual($(element).is(this.$element), true, 'element field is correct'); + assert.strictEqual(event.type, 'dxclick', 'e.event.type is correct'); + assert.strictEqual(event.target, this.$sendButton.get(0), 'event field is correct'); + assert.strictEqual(message.text, text, 'message field is correct'); + }, + }); + + keyboardMock(this.$input).focus().type(text); + + this.$sendButton.trigger('dxclick'); + }); + + QUnit.test('should be possible to change at runtime', function(assert) { + const onMessageSend = sinon.spy(); + + this.instance.option({ onMessageSend }); + + const text = 'new text message'; + + keyboardMock(this.$input) + .focus() + .type(text); + + this.$sendButton.trigger('dxclick'); + + assert.strictEqual(onMessageSend.callCount, 1); + }); + + QUnit.test('new message should be created after clicking the send button', function(assert) { + const text = 'new text message'; + + keyboardMock(this.$input) + .focus() + .type(text); + + this.$sendButton.trigger('dxclick'); + + const $bubbles = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + const bubble = $bubbles[$bubbles.length - 1]; + + assert.strictEqual($(bubble).text(), text); + }); + + QUnit.test('New message should be correct after clicking the send button', function(assert) { + assert.expect(3); + + const text = 'new text message'; + + this.instance.option({ + onMessageSend: ({ message }) => { + const { author, text: messageText } = message; + + assert.strictEqual(author, this.instance.option('user'), 'author field is correct'); + // eslint-disable-next-line no-prototype-builtins + assert.strictEqual(message.hasOwnProperty('timestamp'), true, 'timestamp field is set'); + assert.strictEqual(messageText, text, 'text field is correct'); + }, + }); + + keyboardMock(this.$input) + .focus() + .type(text); + + this.$sendButton.trigger('dxclick'); + }); + }); + }); + + QUnit.module('renderMessage', moduleConfig, () => { + QUnit.test('should allow calling without arguments without any errors', function(assert) { + this.reinit(); + + try { + this.instance.renderMessage(); + } catch(e) { + assert.ok(false, `error: ${e.message}`); + } finally { + const { items } = this.instance.option(); + + assert.strictEqual(items.length, 1, 'message count is correct'); + assert.deepEqual(items[0], {}, 'message data is correct'); + } + }); + + QUnit.test('Chat items should be updated after renderMessage has been called', function(assert) { + const author = { + id: MOCK_CURRENT_USER_ID, + }; + + const newMessage = { + author, + timestamp: NOW, + text: 'NEW MESSAGE', + }; + + this.instance.renderMessage(newMessage); + + const { items } = this.instance.option(); + const lastItem = items[items.length - 1]; + + assert.strictEqual(lastItem, newMessage); + }); + + QUnit.test('Message Group should be created if items are empty', function(assert) { + this.instance.option({ items: [] }); + + const author = { + id: MOCK_CURRENT_USER_ID, + }; + + const newMessage = { + author, + timestamp: NOW, + text: 'NEW MESSAGE', + }; + + const getMessageGroups = () => this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); + + assert.strictEqual(getMessageGroups().length, 0); + + this.instance.renderMessage(newMessage); + + assert.strictEqual(getMessageGroups().length, 1); + }); + + [ + { text: undefined, }, + { text: 'new message text', }, + { text: '', }, + { text: ' ' } + ].forEach((renderMessageArgs) => { + const { text } = renderMessageArgs; + + QUnit.test(`New bubble should be rendered correctly after renderMessage call passed argument ${JSON.stringify(renderMessageArgs)}`, function(assert) { + this.reinit({ + items: [{}, {}, {}], + }); + + const author = { id: MOCK_CURRENT_USER_ID }; + const newMessage = { + author, + text, + }; + + this.instance.renderMessage(newMessage); + + const $bubbles = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($bubbles.length, 4, 'false'); + assert.strictEqual($bubbles.last().text(), text ? text : '', 'text value is correct'); + }); + }); + }); + + QUnit.module('Proxy state options', () => { + [true, false].forEach(value => { + QUnit.test('passed state enabled options should be equal chat state enabled options', function(assert) { + const options = { + activeStateEnabled: value, + focusStateEnabled: value, + hoverStateEnabled: value, + }; + + this.reinit(options); + + const messageBox = MessageBox.getInstance(this.$element.find(`.${CHAT_MESSAGE_BOX_CLASS}`)); + + Object.entries(options).forEach(([key, value]) => { + assert.deepEqual(value, messageBox.option(key), `${key} value is correct`); + }); + }); + + QUnit.test('passed state options should be updated when chat state options are changed in runtime', function(assert) { + const options = { + activeStateEnabled: value, + focusStateEnabled: value, + hoverStateEnabled: value, + }; + + this.instance.option(options); + + const messageBox = MessageBox.getInstance(this.$element.find(`.${CHAT_MESSAGE_BOX_CLASS}`)); + + Object.entries(options).forEach(([key, value]) => { + assert.deepEqual(value, messageBox.option(key), `${key} value is correct`); + }); + }); + }); + }); + + QUnit.module('Methods', () => { + QUnit.test('The textarea input element must be active after the focus() method is invoked', function(assert) { + this.instance.focus(); + + const root = document.querySelector('#qunit-fixture'); + const activeElement = root.shadowRoot ? root.shadowRoot.activeElement : document.activeElement; + + assert.strictEqual(activeElement, this.$input.get(0)); + }); + }); +}); + + diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/header.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/header.markup.tests.js new file mode 100644 index 000000000000..0cdb5ee2f066 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/header.markup.tests.js @@ -0,0 +1,38 @@ +import $ from 'jquery'; + +import ChatHeader from '__internal/ui/chat/chat_header'; + +const CHAT_HEADER_CLASS = 'dx-chat-header'; +const CHAT_HEADER_TEXT_CLASS = 'dx-chat-header-text'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new ChatHeader($('#chatHeader'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('ChatHeader', moduleConfig, () => { + QUnit.module('Classes', () => { + QUnit.test('root element should have correct class', function(assert) { + assert.strictEqual(this.$element.hasClass(CHAT_HEADER_CLASS), true); + }); + + QUnit.test('text element should have correct class', function(assert) { + assert.strictEqual(this.$element.children().first().hasClass(CHAT_HEADER_TEXT_CLASS), true); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/header.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/header.tests.js new file mode 100644 index 000000000000..efd2fc8153cc --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/header.tests.js @@ -0,0 +1,49 @@ +import $ from 'jquery'; + +import ChatHeader from '__internal/ui/chat/chat_header'; + +const moduleConfig = { + beforeEach: function() { + const markup = ''; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new ChatHeader($('#header'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('ChatHeader', moduleConfig, () => { + QUnit.module('Render', () => { + QUnit.test('should be initialized with correct type', function(assert) { + assert.ok(this.instance instanceof ChatHeader); + }); + + QUnit.test('should be rendered with empty value by default', function(assert) { + assert.strictEqual(this.$element.text(), ''); + }); + }); + + QUnit.module('Options', () => { + QUnit.test('should be rendered with passed title option value', function(assert) { + this.reinit({ title: 'Chat title' }); + + assert.strictEqual(this.$element.text(), 'Chat title'); + }); + + QUnit.test('title option should be updatable at runtime', function(assert) { + this.instance.option('title', 'new message text'); + + assert.strictEqual(this.$element.text(), 'new message text'); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.markup.tests.js new file mode 100644 index 000000000000..a7736d6ad758 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.markup.tests.js @@ -0,0 +1,50 @@ +import $ from 'jquery'; + +import MessageBox from '__internal/ui/chat/chat_message_box'; + +const CHAT_MESSAGE_BOX_CLASS = 'dx-chat-message-box'; +const CHAT_MESSAGE_BOX_TEXTAREA_CLASS = 'dx-chat-message-box-text-area'; +const CHAT_MESSAGE_BOX_BUTTON_CLASS = 'dx-chat-message-box-button'; + +const TEXTAREA_CLASS = 'dx-textarea'; +const BUTTON_CLASS = 'dx-button'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new MessageBox($('#messageBox'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('MessageBox', moduleConfig, () => { + QUnit.module('Classes', () => { + QUnit.test('root element should have correct class', function(assert) { + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_BOX_CLASS), true); + }); + + QUnit.test(`textarea field should have ${CHAT_MESSAGE_BOX_TEXTAREA_CLASS} class`, function(assert) { + const $textArea = this.$element.find(`.${TEXTAREA_CLASS}`); + + assert.strictEqual($textArea.hasClass(CHAT_MESSAGE_BOX_TEXTAREA_CLASS), true); + }); + + QUnit.test(`send button should have ${CHAT_MESSAGE_BOX_BUTTON_CLASS} class`, function(assert) { + const $button = this.$element.find(`.${BUTTON_CLASS}`); + + assert.strictEqual($button.hasClass(CHAT_MESSAGE_BOX_BUTTON_CLASS), true); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.tests.js new file mode 100644 index 000000000000..026cdb0843c2 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.tests.js @@ -0,0 +1,204 @@ +import $ from 'jquery'; +import keyboardMock from '../../../helpers/keyboardMock.js'; +import { isRenderer } from 'core/utils/type'; +import config from 'core/config'; + +import MessageBox from '__internal/ui/chat/chat_message_box'; +import TextArea from '__internal/ui/m_text_area'; +import Button from 'ui/button'; + +const CHAT_MESSAGE_BOX_TEXTAREA_CLASS = 'dx-chat-message-box-text-area'; +const CHAT_MESSAGE_BOX_BUTTON_CLASS = 'dx-chat-message-box-button'; + +const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new MessageBox($('#messageBox'), options); + this.$element = $(this.instance.$element()); + + this.$textArea = this.$element.find(`.${CHAT_MESSAGE_BOX_TEXTAREA_CLASS}`); + this.$input = this.$element.find(`.${TEXTEDITOR_INPUT_CLASS}`); + + this.$sendButton = this.$element.find(`.${CHAT_MESSAGE_BOX_BUTTON_CLASS}`); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; +QUnit.module('MessageBox', moduleConfig, () => { + QUnit.module('Render', () => { + QUnit.test('should be initialized with correct type', function(assert) { + assert.ok(this.instance instanceof MessageBox); + }); + + QUnit.test('send button should be initialized with the corresponding configuration', function(assert) { + const expectedOptions = { + icon: 'send', + stylingMode: 'text', + }; + const sendButton = this.$sendButton.dxButton('instance'); + + Object.entries(expectedOptions).forEach(([key, value]) => { + assert.deepEqual(value, sendButton.option(key), `${key} value is correct`); + }); + }); + }); + + QUnit.module('Behavior', () => { + QUnit.test('textarea should be cleared after clicking the send button', function(assert) { + const text = 'new text message'; + + keyboardMock(this.$input) + .focus() + .type(text); + + this.$sendButton.trigger('dxclick'); + + assert.strictEqual(this.$input.val(), ''); + assert.strictEqual(this.$input.val(), ''); + }); + + QUnit.test('textarea should be cleared when the send button is clicked if the input contains a value consisting only of spaces', function(assert) { + const emptyValue = ' '; + + keyboardMock(this.$input) + .focus() + .type(emptyValue); + + this.$sendButton.trigger('dxclick'); + + assert.strictEqual(this.$input.val(), emptyValue); + }); + }); + + QUnit.module('onMessageSend event', () => { + QUnit.test('should be fired when the send button is clicked if the textarea input contains a value', function(assert) { + const onMessageSendStub = sinon.stub(); + + this.reinit({ onMessageSend: onMessageSendStub }); + + keyboardMock(this.$input) + .focus() + .type('new text message'); + + this.$sendButton.trigger('dxclick'); + + assert.strictEqual(onMessageSendStub.callCount, 1); + }); + + QUnit.test('should not be fired when the send button is clicked if the textarea input does not contain a value', function(assert) { + const onMessageSendStub = sinon.stub(); + + this.reinit({ onMessageSend: onMessageSendStub }); + + this.$sendButton.trigger('dxclick'); + + assert.strictEqual(onMessageSendStub.callCount, 0); + }); + + QUnit.test('should be possible to update it at runtime', function(assert) { + const eventHandlerStub = sinon.stub(); + + this.instance.option('onMessageSend', eventHandlerStub); + + keyboardMock(this.$input) + .focus() + .type('text'); + + this.$sendButton.trigger('dxclick'); + + assert.strictEqual(eventHandlerStub.callCount, 1); + }); + + QUnit.test('should not be fired when the send button is clicked if the textarea input contains a value consisting only of spaces', function(assert) { + const emptyText = ' '; + const onMessageSendStub = sinon.stub(); + + this.reinit({ onMessageSend: onMessageSendStub }); + + keyboardMock(this.$input) + .focus() + .type(emptyText); + + this.$sendButton.trigger('dxclick'); + + assert.strictEqual(onMessageSendStub.callCount, 0); + }); + + QUnit.test('should be fired with correct arguments', function(assert) { + assert.expect(6); + + const text = ' new text message '; + + this.reinit({ + onMessageSend: (e) => { + const { component, element, event, text } = e; + + assert.strictEqual(component, this.instance, 'component field is correct'); + assert.strictEqual(isRenderer(element), !!config().useJQuery, 'element is correct'); + assert.strictEqual($(element).is(this.$element), true, 'element field is correct'); + assert.strictEqual(event.type, 'dxclick', 'e.event.type is correct'); + assert.strictEqual(event.target, this.$sendButton.get(0), 'event field is correct'); + assert.strictEqual(text, text, 'message field is correct'); + }, + }); + + keyboardMock(this.$input) + .focus() + .type(text); + + this.$sendButton.trigger('dxclick'); + }); + }); + + QUnit.module('Proxy state options', () => { + [true, false].forEach(value => { + QUnit.test('passed state options should be equal message box state options', function(assert) { + const options = { + activeStateEnabled: value, + focusStateEnabled: value, + hoverStateEnabled: value, + }; + + this.reinit(options); + + const button = Button.getInstance(this.$sendButton); + const textArea = TextArea.getInstance(this.$textArea); + + Object.entries(options).forEach(([key, value]) => { + assert.deepEqual(value, button.option(key), `button ${key} value is correct`); + assert.deepEqual(value, textArea.option(key), `textarea ${key} value is correct`); + }); + }); + + QUnit.test('passed state options should be updated when state options are changed in runtime', function(assert) { + const options = { + activeStateEnabled: value, + focusStateEnabled: value, + hoverStateEnabled: value, + }; + + this.instance.option(options); + + const button = Button.getInstance(this.$sendButton); + const textArea = TextArea.getInstance(this.$textArea); + + Object.entries(options).forEach(([key, value]) => { + assert.deepEqual(value, button.option(key), `button ${key} value is correct`); + assert.deepEqual(value, textArea.option(key), `textarea ${key} value is correct`); + }); + }); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.markup.tests.js new file mode 100644 index 000000000000..3ada4701a86a --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.markup.tests.js @@ -0,0 +1,33 @@ +import $ from 'jquery'; + +import MessageBubble from '__internal/ui/chat/chat_message_bubble'; + +const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new MessageBubble($('#messageBubble'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('MessageBubble', moduleConfig, () => { + QUnit.module('Classes', moduleConfig, () => { + QUnit.test('root element should have correct class', function(assert) { + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_BUBBLE_CLASS), true); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.tests.js new file mode 100644 index 000000000000..4a86bebb085f --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBubble.tests.js @@ -0,0 +1,47 @@ +import $ from 'jquery'; + +import MessageBubble from '__internal/ui/chat/chat_message_bubble'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new MessageBubble($('#messageBubble'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('MessageBubble', moduleConfig, () => { + QUnit.module('Render', () => { + QUnit.test('should be initialized with correct type', function(assert) { + assert.ok(this.instance instanceof MessageBubble); + }); + + QUnit.test('should be rendered with passed text value', function(assert) { + this.reinit({ text: 'message text' }); + + assert.strictEqual(this.$element.text(), 'message text'); + }); + }); + + QUnit.module('Options', () => { + QUnit.test('text option should be updatable at runtime', function(assert) { + this.instance.option('text', 'new message text'); + + assert.strictEqual(this.$element.text(), 'new message text'); + }); + }); +}); + + diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageGroup.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageGroup.markup.tests.js new file mode 100644 index 000000000000..29d87329362c --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageGroup.markup.tests.js @@ -0,0 +1,294 @@ +import $ from 'jquery'; + +import MessageGroup from '__internal/ui/chat/chat_message_group'; + +const CHAT_MESSAGE_GROUP_CLASS = 'dx-chat-message-group'; +const CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS = 'dx-chat-message-group-alignment-start'; +const CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS = 'dx-chat-message-group-alignment-end'; + +const CHAT_MESSAGE_AVATAR_CLASS = 'dx-chat-message-avatar'; +const CHAT_MESSAGE_GROUP_INFORMATION_CLASS = 'dx-chat-message-group-information'; +const CHAT_MESSAGE_TIME_CLASS = 'dx-chat-message-time'; +const CHAT_MESSAGE_AUTHOR_NAME_CLASS = 'dx-chat-message-author-name'; +const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; +const CHAT_MESSAGE_BUBBLE_FIRST_CLASS = 'dx-chat-message-bubble-first'; +const CHAT_MESSAGE_BUBBLE_LAST_CLASS = 'dx-chat-message-bubble-last'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new MessageGroup($('#messageGroup'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('MessageGroup', moduleConfig, () => { + QUnit.module('Classes', moduleConfig, () => { + QUnit.test('root element should have correct class', function(assert) { + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_GROUP_CLASS), true); + }); + + QUnit.test(`root element should have ${CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS} class by default`, function(assert) { + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS), true); + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS), false); + }); + + ['start', 'end'].forEach((alignment) => { + QUnit.test(`root element should have correct alignment class if alignment option value is ${alignment}`, function(assert) { + this.reinit({ + alignment + }); + + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS), alignment === 'start', `${CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS} class`); + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS), alignment === 'end', `${CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS} class`); + }); + + QUnit.test(`root element should have correct alignment class if alignment option value is changed to ${alignment === 'start' ? 'end' : 'start'} at runtime`, function(assert) { + this.reinit({ alignment }); + + const oppositeAlignment = alignment === 'start' ? 'end' : 'start'; + this.instance.option('alignment', oppositeAlignment); + + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS), oppositeAlignment === 'start', `${CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS} class`); + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS), oppositeAlignment === 'end', `${CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS} class`); + }); + + QUnit.test(`avatar should be have correct alignment class if alignment option value is ${alignment}`, function(assert) { + this.reinit({ + alignment + }); + + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS), alignment === 'start', `${CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS} class`); + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS), alignment === 'end', `${CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS} class`); + }); + }); + }); + + QUnit.module('Avatar element', moduleConfig, () => { + QUnit.test('should not be rendered by default', function(assert) { + assert.strictEqual(this.$element.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`).length, 0); + }); + + QUnit.test('should be rendered by default if items is not empty array', function(assert) { + this.reinit({ + items: [{}] + }); + + assert.strictEqual(this.$element.children().first().hasClass(CHAT_MESSAGE_AVATAR_CLASS), true); + }); + + QUnit.test('should not be rendered if alignment is end', function(assert) { + this.reinit({ + items: [{}], + alignment: 'end', + }); + + assert.strictEqual(this.$element.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`).length, 0); + }); + + QUnit.test('should be rendered or removed after change items option at runtime', function(assert) { + this.instance.option('items', [{}]); + + assert.strictEqual(this.$element.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`).length, 1); + + this.instance.option('items', []); + + assert.strictEqual(this.$element.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`).length, 0); + }); + + QUnit.test('should be rendered or removed after change alignment option at runtime', function(assert) { + this.reinit({ + items: [{}], + }); + + this.instance.option('alignment', 'end'); + + assert.strictEqual(this.$element.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`).length, 0); + + this.instance.option('alignment', 'start'); + + assert.strictEqual(this.$element.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`).length, 1); + }); + }); + + QUnit.module('Information element', moduleConfig, () => { + QUnit.test('should not be rendered by default', function(assert) { + const $information = this.$element.find(`.${CHAT_MESSAGE_GROUP_INFORMATION_CLASS}`); + + assert.strictEqual($information.length, 0); + }); + + QUnit.test('should be rendered if items is not empty array', function(assert) { + this.reinit({ + items: [{}], + }); + + const $information = this.$element.find(`.${CHAT_MESSAGE_GROUP_INFORMATION_CLASS}`); + + assert.strictEqual($information.length, 1); + }); + + QUnit.test('should be rendered once for several message bubbles', function(assert) { + this.reinit({ + items: [{}, {}, {}], + }); + + const $information = this.$element.find(`.${CHAT_MESSAGE_GROUP_INFORMATION_CLASS}`); + + assert.strictEqual($information.length, 1); + }); + + QUnit.test('name element should not be rendered if autor.name property is not passed', function(assert) { + this.reinit({ + items: [{ author: {} }], + }); + + const $name = this.$element.find(`.${CHAT_MESSAGE_AUTHOR_NAME_CLASS}`); + + assert.strictEqual($name.length, 1); + assert.strictEqual($name.text(), '', 'name text is empty'); + }); + + QUnit.test('time element should be rendered if timestamp property is not passed', function(assert) { + this.reinit({ + items: [{ }], + }); + + const $time = this.$element.find(`.${CHAT_MESSAGE_TIME_CLASS}`); + + assert.strictEqual($time.length, 1); + assert.strictEqual($time.text(), '', 'name text is empty'); + }); + + QUnit.test('name element should be rendered if autor.name property is passed', function(assert) { + this.reinit({ + items: [{ author: { name: '' } }], + }); + + const $name = this.$element.find(`.${CHAT_MESSAGE_AUTHOR_NAME_CLASS}`); + + assert.strictEqual($name.length, 1); + }); + + QUnit.test('time element should be rendered if timestamp property is passed', function(assert) { + this.reinit({ + items: [{ timestamp: new Date() }], + }); + + const $time = this.$element.find(`.${CHAT_MESSAGE_TIME_CLASS}`); + + assert.strictEqual($time.length, 1); + }); + }); + + QUnit.module('MessageBubble elements', moduleConfig, () => { + QUnit.test('should not be rendered by default', function(assert) { + const $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.length, 0); + }); + + QUnit.test('should be rendered if items is not empty array', function(assert) { + this.reinit({ + items: [{}], + }); + + const $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.length, 1); + }); + + QUnit.test('count should be equal count of items', function(assert) { + this.reinit({ + items: [{}, {}, {}, {}], + }); + + const $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.length, 4); + }); + + QUnit.test('single message should have additional classes', function(assert) { + this.reinit({ + items: [{}], + }); + + const $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.length, 1); + assert.strictEqual($messageBubble.hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), true); + assert.strictEqual($messageBubble.hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), true); + }); + + QUnit.test('first message bubble should have additional class', function(assert) { + this.reinit({ + items: [{}, {}], + }); + + const $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.eq(0).hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), true); + assert.strictEqual($messageBubble.eq(0).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), false); + }); + + QUnit.test('last message bubble should have additional class', function(assert) { + this.reinit({ + items: [{}, {}], + }); + + const $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.eq(1).hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), false); + assert.strictEqual($messageBubble.eq(1).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), true); + }); + + QUnit.test('middle message bubbles should not have additional classes', function(assert) { + this.reinit({ + items: [{}, {}, {}, {}], + }); + + const $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.eq(1).hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), false); + assert.strictEqual($messageBubble.eq(1).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), false); + + assert.strictEqual($messageBubble.eq(2).hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), false); + assert.strictEqual($messageBubble.eq(2).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), false); + }); + + QUnit.test('last class should be deleted from last bubble after renderMessage', function(assert) { + this.reinit({ + items: [{}, {}, {}], + }); + + let $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.eq(2).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), true); + + const newMessage = { + author: { id: 'MikeID' }, + timestamp: Date.now(), + text: 'NEW MESSAGE', + }; + + this.instance.renderMessage(newMessage); + + $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.eq(2).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), false); + assert.strictEqual($messageBubble.eq(3).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), true); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageGroup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageGroup.tests.js new file mode 100644 index 000000000000..5b8faee9320c --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageGroup.tests.js @@ -0,0 +1,104 @@ +import $ from 'jquery'; + +import MessageGroup from '__internal/ui/chat/chat_message_group'; +import ChatAvatar from '__internal/ui/chat/chat_avatar'; + +const CHAT_MESSAGE_AVATAR_CLASS = 'dx-chat-message-avatar'; +const CHAT_MESSAGE_TIME_CLASS = 'dx-chat-message-time'; +const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new MessageGroup($('#messageGroup'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('MessageGroup', moduleConfig, () => { + QUnit.module('Render', () => { + QUnit.test('should be initialized with correct type', function(assert) { + assert.ok(this.instance instanceof MessageGroup); + }); + }); + + QUnit.module('Time', () => { + QUnit.test('value should be presented in the correct format and taken from the first message in the group', function(assert) { + const messageTimeFirst = new Date(2021, 9, 17, 21, 34); + const messageTimeSecond = new Date(2021, 9, 17, 14, 43); + + this.reinit({ + items: [{ + timestamp: messageTimeFirst, + }, { + timestamp: messageTimeSecond + }] + }); + + const $time = this.$element.find(`.${CHAT_MESSAGE_TIME_CLASS}`); + + assert.strictEqual($time.text(), '21:34'); + }); + }); + + QUnit.module('renderMessage()', () => { + QUnit.test('new message bubble should be rendered into the group after calling the renderMessage function', function(assert) { + this.reinit({ + items: [{}, {}, {}], + }); + + let $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.length, 3); + + const newMessage = { + author: { id: 'MikeID' }, + timestamp: Date.now(), + text: 'NEW MESSAGE', + }; + + this.instance.renderMessage(newMessage); + + $messageBubble = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($messageBubble.length, 4); + }); + }); + + QUnit.module('Nested avatar component', () => { + QUnit.test('avatar component should be initialized with correct name property', function(assert) { + [ + { items: [{}], passedNameValue: undefined }, + { items: [{ author: {} }], passedNameValue: undefined }, + { items: [{ author: undefined }], passedNameValue: undefined }, + { items: [{ author: { name: undefined } }], passedNameValue: undefined }, + { items: [{ author: { name: null } }], passedNameValue: null }, + { items: [{ author: { name: '' } }], passedNameValue: '' }, + { items: [{ author: { name: 888 } }], passedNameValue: 888 }, + { items: [{ author: { name: NaN } }], passedNameValue: NaN }, + ].forEach(({ items, passedNameValue }) => { + this.reinit({ + items, + }); + + const avatar = ChatAvatar.getInstance(this.$element.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`)); + + assert.deepEqual(avatar.option('name'), passedNameValue); + }); + }); + }); +}); + + diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.markup.tests.js new file mode 100644 index 000000000000..7ade906c8e63 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.markup.tests.js @@ -0,0 +1,38 @@ +import $ from 'jquery'; + +import MessageList from '__internal/ui/chat/chat_message_list'; + +const CHAT_MESSAGE_LIST_CLASS = 'dx-chat-message-list'; +const SCROLLABLE_CLASS = 'dx-scrollable'; + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new MessageList($('#messageList'), options); + this.$element = $(this.instance.$element()); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('MessageList', moduleConfig, () => { + QUnit.module('Classes', moduleConfig, () => { + QUnit.test('root element should have correct class', function(assert) { + assert.strictEqual(this.$element.hasClass(CHAT_MESSAGE_LIST_CLASS), true); + }); + + QUnit.test('root element should contain scrollable element', function(assert) { + assert.strictEqual(this.$element.children().first().hasClass(SCROLLABLE_CLASS), true); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js new file mode 100644 index 000000000000..03ea8f126da7 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js @@ -0,0 +1,372 @@ +import $ from 'jquery'; + +import MessageList from '__internal/ui/chat/chat_message_list'; +import Scrollable from 'ui/scroll_view/ui.scrollable'; +import { + generateMessages, userFirst, userSecond, + NOW, MOCK_COMPANION_USER_ID, MOCK_CURRENT_USER_ID +} from './chat.tests.js'; +import MessageGroup from '__internal/ui/chat/chat_message_group'; + +const CHAT_MESSAGE_GROUP_CLASS = 'dx-chat-message-group'; +const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; +const SCROLLABLE_CLASS = 'dx-scrollable'; + + +const moduleConfig = { + beforeEach: function() { + const markup = '
'; + $('#qunit-fixture').html(markup); + + const init = (options = {}) => { + this.instance = new MessageList($('#messageList'), options); + this.$element = $(this.instance.$element()); + + this.scrollable = Scrollable.getInstance(this.$element.find(`.${SCROLLABLE_CLASS}`)); + }; + + this.reinit = (options) => { + this.instance.dispose(); + + init(options); + }; + + init(); + } +}; + +QUnit.module('MessageList', moduleConfig, () => { + QUnit.module('Render', () => { + QUnit.test('should be initialized with correct type', function(assert) { + assert.ok(this.instance instanceof MessageList); + }); + + QUnit.test('should not be any errors if the items contain undefined values', function(assert) { + const items = [{}, undefined, {}]; + + try { + this.reinit({ + items, + }); + } catch(e) { + assert.ok(false, `error: ${e.message}`); + } finally { + const $bubbles = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($bubbles.length, 3); + } + }); + + QUnit.test('scrollable should be rendered inside root element', function(assert) { + assert.ok(Scrollable.getInstance(this.$element.children().first()) instanceof Scrollable); + }); + }); + + QUnit.module('MessageGroup integration', () => { + QUnit.test('message group component should not be rendered if items is empty', function(assert) { + const $messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); + + assert.strictEqual($messageGroups.length, 0); + }); + + QUnit.test('new message group component should be rendered only when a message with a different user ID is encountered', function(assert) { + this.reinit({ + items: [ + { author: { id: 'UserID' } }, + { author: { id: 'UserID_1' } }, + { author: { id: 'UserID' } } + ] + }); + + const $messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); + + assert.strictEqual($messageGroups.length, 3); + }); + + QUnit.test('new message group component should not be rendered when a message with the same user ID is encountered', function(assert) { + this.reinit({ + items: [ + { author: { id: 'UserID' } }, + { author: { id: 'UserID' } }, + { author: { id: 'UserID' } } + ] + }); + + const $messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); + + assert.strictEqual($messageGroups.length, 1); + }); + + QUnit.test('new message group component should be rendered when a new message with a different user ID is added to the data at runtime', function(assert) { + const items = [ + { author: { id: 'UserID' } }, + { author: { id: 'UserID_1' } }, + { author: { id: 'UserID' } } + ]; + + this.reinit({ + items + }); + + const newMessage = { + author: { id: 'UserID_1' }, + text: 'NEW MESSAGE', + }; + + this.instance.option({ items: [...items, newMessage] }); + + const $messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); + + assert.strictEqual($messageGroups.length, 4); + }); + + QUnit.test('new message group component should not be rendered when a new message with the same user ID is added to the data at runtime', function(assert) { + const items = [ + { author: { id: 'UserID' } }, + { author: { id: 'UserID_1' } }, + { author: { id: 'UserID' } } + ]; + + this.reinit({ + items + }); + + const newMessage = { + author: { id: 'UserID' }, + text: 'NEW MESSAGE', + }; + + this.instance.option({ items: [...items, newMessage] }); + + const $messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); + + assert.strictEqual($messageGroups.length, 3); + }); + + QUnit.test('should render a message if the new items value contains a single item', function(assert) { + this.reinit(); + + const newMessage = { + timestamp: NOW, + author: userFirst, + text: 'NEW MESSAGE', + }; + + this.instance.option({ items: [ newMessage ] }); + + const $bubbles = this.$element.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($bubbles.length, 1); + }); + + QUnit.test('message group should be rendered with start alignment if user.id is not equal message.author.id', function(assert) { + this.reinit({ + items: [{ author: { id: 'JohnID' } }], + currentUserId: 'MikeID', + }); + + const messageGroup = MessageGroup.getInstance(this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`)); + + assert.strictEqual(messageGroup.option('alignment'), 'start'); + }); + + QUnit.test('message group should be rendered with end alignment if user.id is equal message.author.id', function(assert) { + this.reinit({ + items: [{ author: { id: 'MikeID' } }], + currentUserId: 'MikeID', + }); + + const messageGroup = MessageGroup.getInstance(this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`)); + + assert.strictEqual(messageGroup.option('alignment'), 'end'); + }); + }); + + QUnit.module('Items option change', () => { + QUnit.test('should not be any errors if the new message in items is undefined', function(assert) { + const newMessage = undefined; + const items = [{}]; + + this.reinit({ + items, + }); + + try { + this.instance.option('items', [...items, newMessage]); + } catch(e) { + assert.ok(false, `error: ${e.message}`); + } finally { + assert.ok(true, 'there is no error'); + } + }); + }); + + QUnit.module('Items option change performance', { + beforeEach: function() { + const createInvalidateStub = () => { + this.invalidateStub = sinon.stub(this.instance, '_invalidate'); + }; + + this.recreateInvalidateStub = () => { + createInvalidateStub(); + }; + + createInvalidateStub(); + }, + afterEach: function() { + this.invalidateStub.restore(); + } + }, function() { + QUnit.test('should run invalidate after changing user in runtime', function(assert) { + this.instance.option({ currentUserId: 'newUserID' }); + + assert.strictEqual(this.invalidateStub.callCount, 1); + }); + + QUnit.test('Message list should run invalidate if new value is empty', function(assert) { + this.instance.option({ items: [] }); + + assert.strictEqual(this.invalidateStub.callCount, 1); + }); + + QUnit.test('Message list should run invalidate if previousValue is empty and new value is empty', function(assert) { + this.reinit({}); + this.recreateInvalidateStub(); + + this.instance.option({ items: [] }); + + assert.strictEqual(this.invalidateStub.callCount, 1); + }); + + QUnit.test('Message list should not run invalidate if previousValue is empty and new value has 1 item', function(assert) { + this.reinit({}); + this.recreateInvalidateStub(); + + const newMessage = { + timestamp: NOW, + author: userFirst, + text: 'NEW MESSAGE', + }; + + this.instance.option({ items: [ newMessage ] }); + + assert.strictEqual(this.invalidateStub.callCount, 0); + }); + + QUnit.test('Message list should not run invalidate if 1 new message has been added to items', function(assert) { + const { items } = this.instance.option(); + const newMessage = { + timestamp: NOW, + author: userFirst, + text: 'NEW MESSAGE', + }; + + this.instance.option({ items: [...items, newMessage] }); + + assert.strictEqual(this.invalidateStub.callCount, 0); + }); + + QUnit.test('Message list should run invalidate if new items length is the same as current items length', function(assert) { + const { items } = this.instance.option(); + + const newItems = generateMessages(items.length); + + this.instance.option({ items: newItems }); + + assert.strictEqual(this.invalidateStub.callCount, 1); + }); + + QUnit.test('Message list should run invalidate if new items length less than current items length', function(assert) { + const { items } = this.instance.option(); + + const newItems = generateMessages(items.length - 1); + + this.instance.option({ items: newItems }); + + assert.strictEqual(this.invalidateStub.callCount, 1); + }); + + QUnit.test('Message list should run invalidate if more than 1 new message has been added to items', function(assert) { + const { items } = this.instance.option(); + const newMessage = { + timestamp: NOW, + author: userFirst, + text: 'NEW MESSAGE', + }; + + const newItems = [...items, newMessage, newMessage]; + + this.instance.option({ items: newItems }); + + assert.strictEqual(this.invalidateStub.callCount, 1); + }); + }); + + QUnit.module('Scrollable integration', () => { + QUnit.test('scrollable component should be initialized with correct options', function(assert) { + const expectedOptions = { + useNative: true, + }; + + Object.entries(expectedOptions).forEach(([key, value]) => { + assert.deepEqual(value, this.scrollable.option(key), `${key} value is correct`); + }); + }); + + QUnit.test('Scrollable should be scrolled to last message group after init', function(assert) { + this.reinit({ + width: 300, + height: 600, + items: generateMessages(52) + }); + + const scrollTop = this.scrollable.scrollTop(); + + assert.strictEqual(scrollTop !== 0, true); + }); + + QUnit.test('Scrollable should be scrolled to last message group if items changed at runtime', function(assert) { + this.reinit({ + width: 300, + height: 500, + }); + + this.instance.option('items', generateMessages(52)); + + const scrollable = Scrollable.getInstance(this.$element.find(`.${SCROLLABLE_CLASS}`)); + const scrollTop = scrollable.scrollTop(); + + assert.strictEqual(scrollTop !== 0, true); + }); + + [MOCK_CURRENT_USER_ID, MOCK_COMPANION_USER_ID].forEach(id => { + const isCurrentUser = id === MOCK_CURRENT_USER_ID; + const textName = `Scrollable should be scrolled to last message group after render ${isCurrentUser ? 'current user' : 'companion'} message`; + + QUnit.test(textName, function(assert) { + assert.expect(1); + const items = generateMessages(31); + + this.reinit({ items }); + + const author = { id }; + const newMessage = { + author, + timestamp: NOW, + text: 'NEW MESSAGE', + }; + + this.scrollable.scrollToElement = ($item) => { + const messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); + const lastMessageGroup = messageGroups[messageGroups.length - 1]; + + assert.strictEqual($item, lastMessageGroup); + }; + + this.instance.option('items', [...items, newMessage]); + }); + }); + }); +}); + + diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/editingTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/editingTests.js index 3d1322d9f4fe..550556a2730f 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/editingTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/editingTests.js @@ -8,6 +8,10 @@ import ArrayStore from 'data/array_store'; import 'ui/list'; const LIST_ITEM_CLASS = 'dx-list-item'; +const LIST_SELECT_ALL_CHECKBOX_CLASS = 'dx-list-select-all-checkbox'; +const LIST_SELECT_ALL_CLASS = 'dx-list-select-all'; +const SELECT_CHECKBOX_CLASS = 'dx-list-select-checkbox'; +const SELECT_RADIO_BUTTON_CLASS = 'dx-list-select-radiobutton'; const toSelector = function(cssClass) { return '.' + cssClass; @@ -1278,3 +1282,129 @@ QUnit.test('selection should be updated after items reordered', function(assert) list.reorderItem(this.movedItem, this.destinationItem); assert.deepEqual(list.option('selectedItems'), selection, 'selectedItems option updated'); }); + +QUnit.module('onSelectionChanging', { + beforeEach: function() { + this.dataSource = new DataSource({ + store: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + paginate: true, + pageSize: 3 + }); + } +}, () => { + [true, false].forEach(selectByClick => { + QUnit.test(`should be raised only once on checkBox click if selectByClick=${selectByClick} and selection is cancelled`, function(assert) { + const selectionChangingHandler = sinon.spy((args) => { + args.cancel = true; + }); + + const $list = $('#list').dxList({ + dataSource: this.dataSource, + showSelectionControls: true, + selectByClick, + onSelectionChanging: selectionChangingHandler, + selectionMode: 'multiple' + }); + + const $firstCheckbox = $list.find(`.${SELECT_CHECKBOX_CLASS}`).eq(0); + $firstCheckbox.trigger('dxclick'); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is raised once'); + }); + + QUnit.test(`should be raised only once on radioButton click if selectByClick=${selectByClick} and selection is cancelled`, function(assert) { + const selectionChangingHandler = sinon.spy((args) => { + args.cancel = true; + }); + + const $list = $('#list').dxList({ + dataSource: this.dataSource, + showSelectionControls: true, + selectByClick, + onSelectionChanging: selectionChangingHandler, + selectionMode: 'single' + }); + + const $firstRadioButton = $list.find(`.${SELECT_RADIO_BUTTON_CLASS}`).eq(0); + $firstRadioButton.trigger('dxclick'); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is raised once'); + }); + + QUnit.test(`should be raised only once on checkBox click if selectByClick=${selectByClick} and selection is applied`, function(assert) { + const selectionChangingHandler = sinon.stub(); + + const $list = $('#list').dxList({ + dataSource: this.dataSource, + showSelectionControls: true, + selectByClick, + onSelectionChanging: selectionChangingHandler, + selectionMode: 'multiple' + }); + + const $firstCheckbox = $list.find(`.${SELECT_CHECKBOX_CLASS}`).eq(0); + $firstCheckbox.trigger('dxclick'); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is raised once'); + + const $firstItem = $list.find(`.${LIST_ITEM_CLASS}`); + assert.strictEqual($list.dxList('option', 'focusedElement'), $firstItem.get(0), 'focusedElement is updated correctly'); + }); + + QUnit.test(`should be raised only once on radioButton click if selectByClick=${selectByClick} and selection is applied`, function(assert) { + const selectionChangingHandler = sinon.stub(); + + const $list = $('#list').dxList({ + dataSource: this.dataSource, + showSelectionControls: true, + selectByClick, + onSelectionChanging: selectionChangingHandler, + selectionMode: 'single' + }); + + const $firstRadioButton = $list.find(`.${SELECT_RADIO_BUTTON_CLASS}`).eq(0); + $firstRadioButton.trigger('dxclick'); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is raised once'); + }); + + QUnit.test(`should be raised only once on selectAll checkBox click if selectByClick=${selectByClick} and selection is applied`, function(assert) { + const selectionChangingHandler = sinon.stub(); + + const $list = $('#list').dxList({ + dataSource: this.dataSource, + showSelectionControls: true, + selectByClick, + onSelectionChanging: selectionChangingHandler, + selectionMode: 'all' + }); + + const $selectAllCheckBox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`).eq(0); + $selectAllCheckBox.trigger('dxclick'); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is raised once'); + + const $selectAllItem = $list.find(`.${LIST_SELECT_ALL_CLASS}`); + assert.strictEqual($list.dxList('option', 'focusedElement'), $selectAllItem.get(0), 'focusedElement is updated correctly'); + }); + + QUnit.test(`should be raised only once on selectAll checkbox click if selectByClick=${selectByClick} and selection is cancelled`, function(assert) { + const selectionChangingHandler = sinon.spy((args) => { + args.cancel = true; + }); + + const $list = $('#list').dxList({ + dataSource: this.dataSource, + showSelectionControls: true, + selectByClick, + onSelectionChanging: selectionChangingHandler, + selectionMode: 'all' + }); + + const $selectAllCheckBox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`).eq(0); + $selectAllCheckBox.trigger('dxclick'); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is raised once'); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/editingUITests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/editingUITests.js index c679ad2103d2..bbcc804710e8 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/editingUITests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/editingUITests.js @@ -26,6 +26,9 @@ const LIST_ITEM_ICON_CONTAINER_CLASS = 'dx-list-item-icon-container'; const LIST_ITEM_ICON_CLASS = 'dx-list-item-icon'; const LIST_ITEM_CONTENT_CLASS = 'dx-list-item-content'; const LIST_ITEM_BEFORE_BAG_CLASS = 'dx-list-item-before-bag'; +const LIST_SELECT_ALL_CHECKBOX_CLASS = 'dx-list-select-all-checkbox'; +const LIST_SELECT_ALL_CLASS = 'dx-list-select-all'; +const SELECT_RADIO_BUTTON_CLASS = 'dx-list-select-radiobutton'; const SWITCHABLE_DELETE_READY_CLASS = 'dx-list-switchable-delete-ready'; const SWITCHABLE_MENU_SHIELD_POSITIONING_CLASS = 'dx-list-switchable-menu-shield-positioning'; @@ -1955,7 +1958,7 @@ QUnit.test('next loaded page should be selected when selectAll is enabled', func pageLoadMode: 'nextButton', selectAllMode: 'allPages' }); - const $selectAll = $list.find('.dx-list-select-all .dx-checkbox'); + const $selectAll = $list.find(`.${LIST_SELECT_ALL_CLASS} .dx-checkbox`); const $moreButton = $list.find('.dx-list-next-button > .dx-button').eq(0); $selectAll.trigger('dxclick'); @@ -1976,7 +1979,7 @@ QUnit.test('selectAll should have active state', function(assert) { selectionMode: 'all', selectAllMode: 'allPages' }); - const $selectAll = $list.find('.dx-list-select-all'); + const $selectAll = $list.find(`.${LIST_SELECT_ALL_CLASS}`); const pointer = pointerMock($selectAll); pointer.start('touch').down(); @@ -2002,7 +2005,7 @@ QUnit.test('selectAll should not select items if they are not in current filter' selectionMode: 'all', selectAllMode: 'allPages' }); - const $selectAll = $list.find('.dx-list-select-all .dx-checkbox'); + const $selectAll = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); $selectAll.trigger('dxclick'); @@ -2024,7 +2027,7 @@ QUnit.test('selectAll checkbox should change it\'s state to undefined when one i pageLoadMode: 'nextButton', selectAllMode: 'allPages' }); - const $selectAll = $list.find('.dx-list-select-all .dx-checkbox'); + const $selectAll = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); const $checkBox = $list.find('.dx-checkbox').eq(1); const $moreButton = $list.find('.dx-list-next-button > .dx-button').eq(0); @@ -2050,7 +2053,7 @@ QUnit.test('selectAll should change state after page loading when all items was pageLoadMode: 'nextButton', selectAllMode: 'allPages' }); - const $selectAll = $list.find('.dx-list-select-all .dx-checkbox'); + const $selectAll = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); const $checkBox = $list.find('.dx-checkbox:gt(0)'); const $moreButton = $list.find('.dx-list-next-button > .dx-button').eq(0); @@ -2080,7 +2083,7 @@ QUnit.test('selectAll should change state after page loading if selectAllMode wa $list.dxList('option', 'selectAllMode', 'allPages'); - const $selectAll = $list.find('.dx-list-select-all .dx-checkbox'); + const $selectAll = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); const $checkBox = $list.find('.dx-checkbox').eq(1); const $moreButton = $list.find('.dx-list-next-button > .dx-button').eq(0); @@ -2148,7 +2151,7 @@ QUnit.test('selectAll and unselectAll should log warning if selectAllMode is all selectionMode: 'all', selectAllMode: 'allPages' }); - const $selectAll = $list.find('.dx-list-select-all .dx-checkbox'); + const $selectAll = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); sinon.spy(errors, 'log'); @@ -2158,7 +2161,7 @@ QUnit.test('selectAll and unselectAll should log warning if selectAllMode is all // assert assert.strictEqual(errors.log.callCount, 1); assert.strictEqual(errors.log.lastCall.args[0], 'W1010', 'Warning about selectAllMode allPages and grouped data'); - assert.strictEqual($selectAll.dxCheckBox('option', 'value'), true, 'selectAll checkbox is in selected state'); + assert.strictEqual($selectAll.dxCheckBox('option', 'value'), false, 'selectAll checkbox value is not changed'); assert.strictEqual($list.dxList('option', 'selectedItems').length, 0, 'items are not selected'); @@ -2168,7 +2171,7 @@ QUnit.test('selectAll and unselectAll should log warning if selectAllMode is all // assert assert.strictEqual(errors.log.callCount, 2); assert.strictEqual(errors.log.lastCall.args[0], 'W1010', 'Warning about selectAllMode allPages and grouped data'); - assert.strictEqual($selectAll.dxCheckBox('option', 'value'), false, 'selectAll checkbox is in selected state'); + assert.strictEqual($selectAll.dxCheckBox('option', 'value'), false, 'selectAll checkbox value is not changed'); assert.strictEqual($list.dxList('option', 'selectedItems').length, 0, 'items are not selected'); errors.log.restore(); @@ -2183,7 +2186,7 @@ QUnit.test('render selectAll item when showSelectedAll is true', function(assert selectionMode: 'all', selectAllText: 'Test' }); - const $multipleContainer = $list.find('.dx-list-select-all'); + const $multipleContainer = $list.find(`.${LIST_SELECT_ALL_CLASS}`); assert.ok($multipleContainer.is(':hidden'), 'container for SelectAll is hidden'); }); @@ -2195,7 +2198,7 @@ QUnit.test('selectAll updated on init', function(assert) { showSelectionControls: true, selectionMode: 'all' }); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); assert.strictEqual($checkbox.dxCheckBox('option', 'value'), false, 'selectAll updated after init'); }); @@ -2207,13 +2210,13 @@ QUnit.test('selectAll should be removed when editEnabled switched off', function selectionMode: 'all' }); - assert.strictEqual($list.find('.dx-list-select-all').length, 0, 'selectAll not rendered'); + assert.strictEqual($list.find(`.${LIST_SELECT_ALL_CLASS}`).length, 0, 'selectAll not rendered'); $list.dxList('option', 'showSelectionControls', true); - assert.strictEqual($list.find('.dx-list-select-all').length, 1, 'selectAll rendered'); + assert.strictEqual($list.find(`.${LIST_SELECT_ALL_CLASS}`).length, 1, 'selectAll rendered'); $list.dxList('option', 'showSelectionControls', false); - assert.strictEqual($list.find('.dx-list-select-all').length, 0, 'selectAll not rendered'); + assert.strictEqual($list.find(`.${LIST_SELECT_ALL_CLASS}`).length, 0, 'selectAll not rendered'); }); QUnit.test('selectAll selects all items', function(assert) { @@ -2225,7 +2228,7 @@ QUnit.test('selectAll selects all items', function(assert) { selectionMode: 'all' }); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); $checkbox.trigger('dxclick'); assert.deepEqual($list.dxList('option', 'selectedItems'), items, 'all items selected'); @@ -2242,7 +2245,7 @@ QUnit.test('selectAll triggers callback when selects all items', function(assert onSelectAllValueChanged: selectAllSpy }); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); $checkbox.trigger('dxclick'); assert.strictEqual(selectAllSpy.callCount, 1); @@ -2262,7 +2265,7 @@ QUnit.test('selectAll triggers changed callback when selects all items', functio const list = $list.dxList('instance'); list.option('onSelectAllValueChanged', selectAllSpy); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); $checkbox.trigger('dxclick'); assert.strictEqual(selectAllSpy.callCount, 1); @@ -2280,7 +2283,7 @@ QUnit.test('selectAll triggers selectAllValueChanged event when selects all item const list = $list.dxList('instance'); list.on('selectAllValueChanged', selectAllSpy); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); $checkbox.trigger('dxclick'); assert.strictEqual(selectAllSpy.callCount, 1); @@ -2294,7 +2297,7 @@ QUnit.test('selectAll unselect all items when all items selected', function(asse showSelectionControls: true, selectionMode: 'all' }); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); $checkbox.trigger('dxclick'); assert.strictEqual($list.dxList('option', 'selectedItems').length, 0, 'all items unselected'); @@ -2311,7 +2314,7 @@ QUnit.test('selectAll triggers callback when unselect all items when all items s assert.strictEqual(args.value, false, 'all items selected'); } }); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); $checkbox.trigger('dxclick'); }); @@ -2323,7 +2326,7 @@ QUnit.test('selectAll selects all items when click on item', function(assert) { showSelectionControls: true, selectionMode: 'all' }); - const $selectAll = $list.find('.dx-list-select-all'); + const $selectAll = $list.find(`.${LIST_SELECT_ALL_CLASS}`); $selectAll.trigger('dxclick'); @@ -2337,7 +2340,7 @@ QUnit.test('selectAll selects all items when click on checkBox and selectionType showSelectionControls: true, selectionMode: 'all' }); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); $checkbox.trigger('dxclick'); @@ -2354,7 +2357,7 @@ QUnit.test('selectAll checkbox is selected when all items selected', function(as $items.trigger('dxclick'); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); assert.strictEqual($checkbox.dxCheckBox('option', 'value'), true, 'selectAll checkbox selected'); }); @@ -2373,7 +2376,7 @@ QUnit.test('selectAll checkbox is selected when all items selected (ds w/o total $items.trigger('dxclick'); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); assert.strictEqual($checkbox.dxCheckBox('option', 'value'), true, 'selectAll checkbox selected'); }); @@ -2393,7 +2396,7 @@ QUnit.test('selectAll checkbox is selected when all items selected (ds with tota $items.trigger('dxclick'); - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); assert.strictEqual($checkbox.dxCheckBox('option', 'value'), true, 'selectAll checkbox checked'); }); @@ -2408,7 +2411,7 @@ QUnit.test('selectAll checkbox has indeterminate state when not all items select $items.trigger('dxclick'); // NOTE: select all $items.eq(0).trigger('dxclick'); // NOTE: unselect first - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); assert.strictEqual($checkbox.dxCheckBox('option', 'value'), undefined, 'selectAll checkbox has indeterminate state'); }); @@ -2423,7 +2426,7 @@ QUnit.test('selectAll checkbox is unselected when all items unselected', functio $items.trigger('dxclick'); // NOTE: select all $items.trigger('dxclick'); // NOTE: unselect all - const $checkbox = $list.find('.dx-list-select-all .dx-checkbox'); + const $checkbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); assert.strictEqual($checkbox.dxCheckBox('option', 'value'), false, 'selectAll checkbox is unselected'); }); @@ -2439,7 +2442,7 @@ QUnit.test('selectAll checkbox should be updated after load next page', function selectionMode: 'all' }); - const $selectAll = $list.find('.dx-list-select-all .dx-checkbox'); + const $selectAll = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`); $selectAll.trigger('dxclick'); assert.strictEqual($selectAll.dxCheckBox('option', 'value'), true, 'selectAll checkbox is selected'); @@ -2468,21 +2471,79 @@ QUnit.test('onContentReady event should be called after update the state Select showSelectionControls: true, selectionMode: 'all', onContentReady: (e) => { - $(e.element).find('.dx-list-select-all-checkbox').dxCheckBox('instance').option('value', undefined); + $(e.element).find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`).dxCheckBox('instance').option('value', undefined); } }); clock.tick(100); - assert.ok($list.find('.dx-list-select-all-checkbox').hasClass('dx-checkbox-indeterminate'), 'checkbox in an indeterminate state'); + assert.ok($list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`).hasClass('dx-checkbox-indeterminate'), 'checkbox in an indeterminate state'); clock.restore(); }); +QUnit.module('onSelectionChanging', { + beforeEach: function() { + this.dataSource = new DataSource({ + store: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + paginate: true, + pageSize: 3 + }); + } +}, () => { + QUnit.test('checkBox state should not be updated if selection is cancelled', function(assert) { + const $list = $('#list').dxList({ + dataSource: this.dataSource, + showSelectionControls: true, + onSelectionChanging: (args) => { + args.cancel = true; + }, + selectionMode: 'multiple' + }); -QUnit.module('item select decorator with single selection mode'); + const $firstCheckbox = $list.find(`.${SELECT_CHECKBOX_CLASS}`).eq(0); + $firstCheckbox.trigger('dxclick'); -const SELECT_RADIO_BUTTON_CLASS = 'dx-list-select-radiobutton'; + assert.strictEqual($firstCheckbox.dxCheckBox('option', 'value'), false, 'checkbox is not checked'); + }); + + QUnit.test('selectAll checkBox state should not be updated if selection is cancelled', function(assert) { + const $list = $('#list').dxList({ + dataSource: this.dataSource, + showSelectionControls: true, + onSelectionChanging: (args) => { + args.cancel = true; + }, + selectionMode: 'all', + }); + + const $selectAllCheckbox = $list.find(`.${LIST_SELECT_ALL_CHECKBOX_CLASS}`).eq(0); + $selectAllCheckbox.trigger('dxclick'); + + assert.strictEqual($selectAllCheckbox.dxCheckBox('option', 'value'), false, 'selectAll checkbox is not checked'); + $selectAllCheckbox.trigger('dxclick'); + assert.strictEqual($selectAllCheckbox.dxCheckBox('option', 'value'), false, 'selectAll checkbox is not checked'); + }); + + QUnit.test('radioButton state should not be updated if selection is cancelled', function(assert) { + const $list = $('#list').dxList({ + dataSource: this.dataSource, + showSelectionControls: true, + onSelectionChanging: (args) => { + args.cancel = true; + }, + selectionMode: 'single' + }); + + const $firstRadioButton = $list.find(`.${SELECT_RADIO_BUTTON_CLASS}`).eq(0); + $firstRadioButton.trigger('dxclick'); + + assert.strictEqual($firstRadioButton.dxRadioButton('option', 'value'), false, 'radioButton is not checked'); + }); +}); + + +QUnit.module('item select decorator with single selection mode'); QUnit.test('item click changes radio button state only to true in single selection mode', function(assert) { const $list = $('#templated-list').dxList({ diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/multiView.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/multiView.tests.js index 995043e44171..9fd7c6731c52 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/multiView.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/multiView.tests.js @@ -438,6 +438,48 @@ QUnit.module('interaction via swipe', { animation.complete = this.origCompleteAnimation; } }, () => { + QUnit.module('onSelectionChanging', { + beforeEach: function() { + this.selectionChangingStub = sinon.stub(); + this.selectionChangedStub = sinon.stub(); + this.$multiView = $('#multiView').dxMultiView({ + items: [1, 2, 3], + selectedIndex: 0, + swipeEnabled: true, + onSelectionChanging: this.selectionChangingStub, + onSelectionChanged: this.selectionChangedStub + }); + this.multiView = $('#multiView').dxMultiView('instance'); + this.pointer = pointerMock(this.$multiView); + } + }, () => { + QUnit.test('should not cancel selection if e.cancel is not modified', function(assert) { + this.pointer.start().swipeStart().swipe(-0.5).swipeEnd(-1); + + assert.strictEqual(this.selectionChangingStub.callCount, 1, 'onSelectionChanging should be called'); + assert.strictEqual(this.selectionChangedStub.callCount, 1, 'onSelectionChanged should be called'); + + assert.strictEqual(this.multiView.option('selectedIndex'), 1, 'selected index is updated'); + }); + + QUnit.test('should cancel selection if e.cancel=true', function(assert) { + this.selectionChangingStub = sinon.spy((e) => { + e.cancel = true; + }); + this.multiView.option('onSelectionChanging', this.selectionChangingStub); + + this.pointer.start().swipeStart().swipe(-0.5).swipeEnd(-1); + + assert.strictEqual(this.selectionChangingStub.callCount, 1, 'onSelectionChanging should be called'); + assert.strictEqual(this.selectionChangedStub.callCount, 0, 'onSelectionChanged is not called'); + + assert.strictEqual(this.multiView.option('selectedIndex'), 0, 'selected index is not changed'); + + const $itemContainer = this.$multiView.find(`.${MULTIVIEW_ITEM_CONTAINER_CLASS}`); + assert.strictEqual(position($itemContainer), 0, 'container position is restored to initial'); + }); + }); + QUnit.test('item container should not be moved by swipe if items count less then 2', function(assert) { const $multiView = $('#multiView').dxMultiView({ items: [1] diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/pager.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/pager.tests.js index db4b62960f32..c011dc82323c 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/pager.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/pager.tests.js @@ -2,6 +2,7 @@ import { setWidth, getWidth, getOuterWidth } from 'core/utils/size'; import $ from 'jquery'; import commonUtils from 'core/utils/common'; import typeUtils from 'core/utils/type'; +import resizeCallbacks from 'core/utils/resize_callbacks'; import 'generic_light.css!'; import Pager from 'ui/pager'; @@ -28,6 +29,16 @@ function isLightMode(pager) { return pager.$element().hasClass('dx-light-mode'); } +let resizingInProcess = false; +function _dimensionChanged(pager) { + if(!resizingInProcess) { + resizingInProcess = true; + resizeCallbacks.fire(); + pager._refresh(); + } + resizingInProcess = false; +} + QUnit.module('Pager', { beforeEach: function() { this.checkPages = function($pager, values, selectedValue) { @@ -269,7 +280,7 @@ function() { assert.equal(getText(pagesElement[6]), '8', 'last page'); }); - QUnit.test.skip('Select page after click', function(assert) { + QUnit.test('Select page after click', function(assert) { const testElement = $('#container'); const $pager = testElement.dxPager({ maxPagesCount: 7, pageCount: 8 }); @@ -286,15 +297,16 @@ function() { assert.equal(getText(pagesElement[7]), '8', 'last page'); }); - QUnit.test.skip('Select page after pointer up', function(assert) { + QUnit.test('Select page after pointer up', function(assert) { const testElement = $('#container'); const $pager = testElement.dxPager({ maxPagesCount: 7, pageCount: 8 }); - const instance = $pager.dxPager('instance'); - $(instance._pages[4]._$page).trigger('dxpointerup'); - $(instance._pages[4]._$page).trigger('dxclick'); + let pagesElement = getPagesElement(testElement); - const pagesElement = getPagesElement(testElement); + $(pagesElement[4]).trigger('dxpointerup'); + $(pagesElement[4]).trigger('dxclick'); + + pagesElement = getPagesElement(testElement); assert.equal(pagesElement.length, 8, 'pages elements count'); assert.equal(getText(pagesElement[0]), '1', 'page 1'); assert.equal(getText(pagesElement[1]), '. . .', 'separator'); @@ -345,18 +357,17 @@ function() { assert.equal($pages.length, 0, '$pages count'); }); - QUnit.test.skip('Change pages count', function(assert) { + QUnit.test('Change pages count', function(assert) { const testElement = $('#container'); const $pager = testElement.dxPager({ maxPagesCount: 7, pageCount: 8 }); const instance = $pager.dxPager('instance'); let pagesElement; - - $(instance._pages[4]._$page).trigger('dxclick'); - - pagesElement = getPagesElement(testElement); - assert.equal(instance.selectedPage.value(), '5', 'selected page'); + $(pagesElement[4]).trigger('dxclick'); + pagesElement = getPagesElement(testElement); + + assert.equal(instance.option('pageIndex'), '5', 'selected page'); assert.equal(pagesElement.length, 8, 'pages elements count'); assert.equal(getText(pagesElement[0]), '1', 'page 1'); assert.equal(getText(pagesElement[1]), '. . .', 'separator'); @@ -368,9 +379,9 @@ function() { assert.equal(getText(pagesElement[7]), '8', 'last page'); instance.option('pageCount', 9); - pagesElement = getPagesElement(testElement); - assert.equal(instance.selectedPage.value(), '5', 'selected page'); + + assert.equal(instance.option('pageIndex'), '5', 'selected page'); assert.equal(pagesElement.length, 8, 'pages elements count'); assert.equal(getText(pagesElement[0]), '1', 'page 1'); assert.equal(getText(pagesElement[1]), '. . .', 'separator'); @@ -427,7 +438,7 @@ function() { assert.equal(pageSizesElements.length, 0, 'page size elements count'); }); - QUnit.test.skip('Page size selection by click', function(assert) { + QUnit.test('Page size selection by click', function(assert) { $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, pageIndex: 1, pageSizes: [5, 10, 20] }); let pageSizesElements = $('.dx-page-size'); @@ -455,7 +466,7 @@ function() { assert.equal(getText(selectionPageSizesElements[0]), '10', 'page size = 10'); }); - QUnit.test.skip('Page size is changed when selected page is clicked', function(assert) { + QUnit.test('Page size is changed when selected page is clicked', function(assert) { let pageSizeChanged; $('#container').dxPager({ @@ -501,32 +512,31 @@ function() { assert.ok(pageSizeChanged); }); - QUnit.test.skip('Correct selected page when page index is not contains in the pages', function(assert) { + QUnit.test('Correct selected page when page index is not contains in the pages', function(assert) { const $pager = $('#container').dxPager({ maxPagesCount: 8, pageCount: 25, pageIndex: 1, pageSizes: [5, 10, 20] }); const instance = $pager.dxPager('instance'); instance.option('pageIndex', 16); - assert.equal(instance._pages[1].value(), 15, '1 page value'); - assert.equal(instance._pages[1].index, 1, '1 page index'); - assert.equal(instance._pages[2].value(), 16, '2 page value'); - assert.equal(instance._pages[2].index, 2, '1 page index'); - assert.equal(instance._pages[3].value(), 17, '3 page value'); - assert.equal(instance._pages[3].index, 3, '1 page index'); - assert.equal(instance._pages[4].value(), 18, '4 page value'); - assert.equal(instance._pages[4].index, 4, '1 page index'); - assert.ok(instance._pages[2]._$page.hasClass('dx-page'), 'page is selected'); + let pagesElement = getPagesElement($('#container')); + + assert.equal(getText(pagesElement[0]), '1', '1 page value'); + assert.equal(getText(pagesElement[1]), '. . .', 'separator'); + assert.equal(getText(pagesElement[2]), '15', '2 page value'); + assert.equal(getText(pagesElement[3]), '16', '3 page value'); + assert.ok($(pagesElement[3]).is('.dx-selection'), '16 selected page'); + assert.equal(getText(pagesElement[4]), '17', '4 page value'); + assert.equal(getText(pagesElement[5]), '18', '5 page value'); instance.option('pageIndex', 22); - assert.equal(instance._pages[1].value(), 21, '1 page value'); - assert.equal(instance._pages[1].index, 1, '1 page index'); - assert.equal(instance._pages[2].value(), 22, '2 page value'); - assert.equal(instance._pages[2].index, 2, '1 page index'); - assert.equal(instance._pages[3].value(), 23, '3 page value'); - assert.equal(instance._pages[3].index, 3, '1 page index'); - assert.equal(instance._pages[4].value(), 24, '4 page value'); - assert.equal(instance._pages[4].index, 4, '1 page index'); - assert.ok(instance._pages[3]._$page.hasClass('dx-page'), 'page is selected'); + pagesElement = getPagesElement($('#container')); + assert.equal(getText(pagesElement[0]), '1', '1 page value'); + assert.equal(getText(pagesElement[1]), '. . .', 'separator'); + assert.equal(getText(pagesElement[2]), '21', '2 page value'); + assert.equal(getText(pagesElement[3]), '22', '2 page value'); + assert.ok($(pagesElement[3]).is('.dx-selection'), '22 selected page'); + assert.equal(getText(pagesElement[4]), '23', '2 page value'); + assert.equal(getText(pagesElement[5]), '24', '2 page value'); }); QUnit.test('Refresh pages after page size is changed_B233925', function(assert) { @@ -655,7 +665,7 @@ function() { assert.equal($pager.find('.dx-next-button').length, 1, 'next button'); }); - QUnit.test.skip('Next page index via navigate button', function(assert) { + QUnit.test('Next page index via navigate button', function(assert) { const $pager = $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, pageSizes: [5, 10, 20], showNavigationButtons: true }); const instance = $pager.dxPager('instance'); @@ -666,12 +676,12 @@ function() { $button = $('.dx-next-button'); $($button).trigger('dxclick'); - assert.equal(instance.selectedPage.value(), '4', 'selected page index 4'); + assert.equal(instance.option('pageIndex'), '4', 'selected page index 4'); instance.option('pageIndex', 10); $($button).trigger('dxclick'); - assert.equal(instance.selectedPage.value(), '10', 'selected page index 10'); + assert.equal(instance.option('pageIndex'), '10', 'selected page index 10'); }); QUnit.test('Focus selected page', function(assert) { @@ -683,7 +693,7 @@ function() { } }); - QUnit.test.skip('Back page index via navigate button', function(assert) { + QUnit.test('Back page index via navigate button', function(assert) { const $pager = $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, pageSizes: [5, 10, 20], showNavigationButtons: true }); const instance = $pager.dxPager('instance'); @@ -695,7 +705,7 @@ function() { $($prevButton).trigger('dxclick'); - assert.equal(instance.selectedPage.value(), '6', 'selected page index 6'); + assert.equal(instance.option('pageIndex'), 6, 'selected page index 6'); instance.option('pageIndex', 1); @@ -705,10 +715,10 @@ function() { $($prevButton).trigger('dxclick'); - assert.equal(instance.selectedPage.value(), '1', 'selected page index 1'); + assert.equal(instance.option('pageIndex'), 1, 'selected page index 1'); }); - QUnit.test.skip('Click on navigate buttons', function(assert) { + QUnit.test('Click on navigate buttons', function(assert) { const $pager = $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, @@ -733,7 +743,7 @@ function() { }); // T804551 - QUnit.test.skip('Pointer up and click on page button', function(assert) { + QUnit.test('Pointer up and click on page button', function(assert) { const $pager = $('#container').dxPager({ pageCount: 20 }); const instance = $pager.dxPager('instance'); @@ -744,7 +754,7 @@ function() { assert.equal(instance.option('pageIndex'), 5, 'pageIndex is correct'); }); - QUnit.test.skip('Prev button is disabled when first page is chosen ', function(assert) { + QUnit.test('Prev button is disabled when first page is chosen ', function(assert) { const $pager = $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, pageSizes: [5, 10, 20], showNavigationButtons: true }); let isPageChanged; const $button = $('.dx-prev-button'); @@ -760,7 +770,7 @@ function() { assert.ok(!isPageChanged); }); - QUnit.test.skip('Next button is disabled when first page is chosen ', function(assert) { + QUnit.test('Next button is disabled when first page is chosen ', function(assert) { const $pager = $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, pageSizes: [5, 10, 20], showNavigationButtons: true }); let isPageChanged; const instance = $pager.dxPager('instance'); @@ -777,7 +787,7 @@ function() { assert.ok(!isPageChanged); }); - QUnit.test.skip('Next button is disabled when first page is chosen (Rtl mode)', function(assert) { + QUnit.test('Next button is disabled when first page is chosen (Rtl mode)', function(assert) { $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, @@ -793,7 +803,7 @@ function() { assert.ok($button.hasClass('dx-button-disable')); }); - QUnit.test.skip('Prev button is disabled when first page is chosen (Rtl mode)', function(assert) { + QUnit.test('Prev button is disabled when first page is chosen (Rtl mode)', function(assert) { const $pager = $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, @@ -908,7 +918,7 @@ function() { assert.equal($pager.find('.dx-next-button').length, 1, 'next button'); }); - QUnit.test.skip('Light mode. Change page index after clicked on the pages count element', function(assert) { + QUnit.test('Light mode. Change page index after clicked on the pages count element', function(assert) { $('#container').width(PAGER_LIGHT_MODE_WIDTH).dxPager({ maxPagesCount: 8, pageCount: 110, @@ -1034,7 +1044,7 @@ function() { assert.equal(selectBox.option('value'), 13); }); - QUnit.test.skip('Light mode. Change page sizes via option method', function(assert) { + QUnit.test('Light mode. Change page sizes via option method', function(assert) { const $pager = $('#container').width(PAGER_LIGHT_MODE_WIDTH).dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1127,7 +1137,7 @@ function() { assert.equal(numberBox.option('value'), 79); }); - QUnit.test.skip('Light mode. Change page index via the navigation buttons', function(assert) { + QUnit.test('Light mode. Change page index via the navigation buttons', function(assert) { let pageIndex; $('#container').width(PAGER_LIGHT_MODE_WIDTH).dxPager({ maxPagesCount: 8, @@ -1184,7 +1194,7 @@ function() { assert.equal(pageIndex, 10, '23 value'); }); - QUnit.test.skip('Apply light mode when width of pager is less of min width', function(assert) { + QUnit.test('Apply light mode when width of pager is less of min width', function(assert) { const $pager = $('#container').width(1000).dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1198,14 +1208,14 @@ function() { const pager = $pager.dxPager('instance'); assert.equal(isLightMode(pager), false, 'lightModeEnabled by default'); - assert.ok(!pager._isLightMode, 'isLightMode'); $pager.width(100); + _dimensionChanged(pager); assert.equal(isLightMode(pager), true, 'lightModeEnabled is enabled'); }); - QUnit.test.skip('Apply light mode when width equal optimal pager\'s width', function(assert) { + QUnit.test('Apply light mode when width equal optimal pager\'s width', function(assert) { const $pager = $('#container').width(1000).dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1218,17 +1228,21 @@ function() { }); const pager = $pager.dxPager('instance'); + const pagesElement = getPagesElement($('#container')); - const optimalPagerWidth = getWidth(pager._$pagesSizeChooser) + getWidth(pager._$pagesChooser) - getWidth(pager._pages[pager._pages.length - 1]._$page); + const optimalPagerWidth = getWidth( + $pager.find('.dx-page-sizes')) + + getWidth($pager.find('.dx-pages')) - + getWidth(pagesElement[pagesElement.length - 1]); - $pager.width(optimalPagerWidth - getOuterWidth(pager._$info, true) - 1); + $pager.width(optimalPagerWidth - getOuterWidth($pager.find('.dx-info'), true) - 1); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.equal(isLightMode(pager), true, 'lightModeEnabled is enabled'); }); // T962160 - QUnit.test.skip('Show info after pagesizes change', function(assert) { + QUnit.test('Show info after pagesizes change', function(assert) { const $pager = $('#container').width(1000).dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1240,18 +1254,20 @@ function() { const pager = $pager.dxPager('instance'); - const optimalPagerWidth = getWidth(pager._$pagesSizeChooser) + getWidth(pager._$pagesChooser) + 20; + const optimalPagerWidth = getWidth($pager.find('.dx-page-sizes')) + getWidth($pager.find('.dx-pages')) + 20; $pager.width(optimalPagerWidth); - pager._dimensionChanged(); - assert.ok(pager._$info.length === 1 && pager._$info.css('display') !== 'none', 'info element is visible'); + _dimensionChanged(pager); + + const pagesElement = getPagesElement($('#container')); + assert.ok($pager.find('.dx-info').length === 1 && $pager.find('.dx-info').css('display') !== 'none', 'info element is visible'); - $(pager._pages[4]._$page).trigger('dxclick'); - pager._dimensionChanged(); - assert.ok(pager._$info.length === 0 || pager._$info.css('display') === 'none', 'info element is hidden'); + $(pagesElement[4]).trigger('dxclick'); + _dimensionChanged(pager); + assert.ok($pager.find('.dx-info').length === 0 || $pager.find('.dx-info').css('display') === 'none', 'info element is hidden'); - $(pager._pages[0]._$page).trigger('dxclick'); - pager._dimensionChanged(); - assert.ok(pager._$info.length === 1 && pager._$info.css('display') !== 'none', 'info element is visible'); + $(pagesElement[0]).trigger('dxclick'); + _dimensionChanged(pager); + assert.ok($pager.find('.dx-info').length === 1 && $pager.find('.dx-info').css('display') !== 'none', 'info element is visible'); }); QUnit.test('Apply light mode when pager is first rendered', function(assert) { @@ -1270,7 +1286,7 @@ function() { assert.equal(isLightMode(pager), true, 'lightModeEnabled is enabled'); }); - QUnit.test.skip('Pager is rendered in a normal view after light mode when pageCount is changed', function(assert) { + QUnit.test('Pager is rendered in a normal view after light mode when pageCount is changed', function(assert) { const $pager = $('#container').width(460).dxPager({ maxPagesCount: 10, pageCount: 5, @@ -1287,10 +1303,10 @@ function() { pager.option({ pageCount: 10, pageIndexChanged: commonUtils.noop }); pager.option({ pageCount: 5, pageIndexChanged: commonUtils.noop }); - // assert.strictEqual(isLightMode(pager), isRenovation, `pager is ${isRenovation ? '' : 'not'} displayed in the light mode for pager`); + assert.strictEqual(isLightMode(pager), true, 'pager is displayed in the light mode for pager'); }); - QUnit.test.skip('Light mode is applied only one', function(assert) { + QUnit.test('Light mode is applied only one', function(assert) { const $pager = $('#container').width(1000).dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1306,32 +1322,32 @@ function() { const pageSizeEl = $pager.find('.dx-page-sizes')[0].children[0]; $pager.width(995); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.ok(!isLightMode(pager), 'pager is not displayed in the light mode width:995'); assert.equal(pageSizeEl, $pager.find('.dx-page-sizes')[0].children[0], 'pages not re-render:995'); $pager.width(800); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.ok(!isLightMode(pager), 'pager is not displayed in the light mode width:800'); assert.equal(pageSizeEl, $pager.find('.dx-page-sizes')[0].children[0], 'pages not re-render width:880'); $pager.width(100); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.ok(isLightMode(pager), 'pager is displayed in the light mode width:100'); assert.notStrictEqual(pageSizeEl, $pager.find('.dx-page-sizes')[0].children[0], 'pages re-render width:100'); const pageSizeElLight = $pager.find('.dx-page-sizes')[0].children[0]; $pager.width(80); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.ok(isLightMode(pager), 'pager is displayed in the light mode width:80'); assert.equal(pageSizeElLight, $pager.find('.dx-page-sizes')[0].children[0], 'pages not re-render width:80'); }); - QUnit.test.skip('Cancel light mode when width of pager is more of min width', function(assert) { + QUnit.test('Cancel light mode when width of pager is more of min width', function(assert) { const $pager = $('#container').width(100).dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1347,12 +1363,12 @@ function() { assert.equal(isLightMode(pager), true, 'lightModeEnabled is enabled'); $pager.width(1000); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.equal(isLightMode(pager), false, 'lightModeEnabled is disabled'); }); - QUnit.test.skip('Cancel light mode is only one', function(assert) { + QUnit.test('Cancel light mode is only one', function(assert) { const $pager = $('#container').width(100).dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1370,32 +1386,32 @@ function() { assert.ok(isLightMode(pager), 'pager is displayed in the light mode width:100'); $pager.width(1000); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.ok(!isLightMode(pager), 'pager is not displayed in the light mode width:1000'); assert.notStrictEqual(pageSizeEl, $pager.find('.dx-page-sizes')[0].children[0], 'pages not re-render:1000'); const pageSizeLargeEl = $pager.find('.dx-page-sizes')[0].children[0]; $pager.width(1005); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.ok(!isLightMode(pager), 'pager is not displayed in the light mode width:1005'); assert.equal(pageSizeLargeEl, $pager.find('.dx-page-sizes')[0].children[0], 'pages not re-render:1005'); $pager.width(1010); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.ok(!isLightMode(pager), 'pager is not displayed in the light mode width:1010'); assert.equal(pageSizeLargeEl, $pager.find('.dx-page-sizes')[0].children[0], 'pages not re-render:1010'); $pager.width(1200); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.ok(!isLightMode(pager), 'pager is not displayed in the light mode width:1010'); assert.equal(pageSizeLargeEl, $pager.find('.dx-page-sizes')[0].children[0], 'pages not re-render:1010'); }); - QUnit.test.skip('Hide the info element when it does not fit in a container', function(assert) { + QUnit.test('Hide the info element when it does not fit in a container', function(assert) { const $pager = $('#container').width(1000).dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1407,14 +1423,14 @@ function() { }); const pager = $pager.dxPager('instance'); - $pager.width(getWidth(pager._$pagesSizeChooser) + getWidth(pager._$pagesChooser) - 50); - pager._dimensionChanged(); + $pager.width(getWidth($pager.find('.dx-page-sizes')) + getWidth($pager.find('.dx-pages')) - 50); + _dimensionChanged(pager); assert.ok(!isLightMode(pager), 'lightModeEnabled'); - assert.ok(pager._$info.length === 0 || pager._$info.css('display') === 'none', 'info element is hidden'); + assert.ok($pager.find('.dx-info').length === 0 || $pager.find('.dx-info').css('display') === 'none', 'info element is hidden'); }); - QUnit.test.skip('Show the info element when it is fit in a container', function(assert) { + QUnit.test('Show the info element when it is fit in a container', function(assert) { const $pager = $('#container').width(1000).dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1429,18 +1445,18 @@ function() { setWidth( $pager, - getWidth(pager._$pagesSizeChooser) + getWidth(pager._$pagesChooser) - 50 + getWidth($pager.find('.dx-page-sizes')) + getWidth($pager.find('.dx-pages')) - 50 ); - pager._dimensionChanged(); + _dimensionChanged(pager); setWidth( $pager, - getWidth(pager._$pagesSizeChooser) + getWidth(pager._$pagesChooser) + infoWidth + 50 + getWidth($pager.find('.dx-page-sizes')) + getWidth($pager.find('.dx-pages')) + infoWidth + 50 ); - pager._dimensionChanged(); + _dimensionChanged(pager); assert.ok(!isLightMode(pager), 'lightModeEnabled'); - assert.ok(pager._$info.length === 1 || pager._$info.css('display') !== 'none', 'info element is hidden'); + assert.ok($pager.find('.dx-info').length === 1 || $pager.find('.dx-info').css('display') !== 'none', 'info element is hidden'); }); QUnit.test('LightMode.Prev button is disabled when first page is chosen ', function(assert) { @@ -1492,7 +1508,7 @@ function() { assert.ok(!isPageChanged); }); - QUnit.test.skip('Navigate buttons with rtl', function(assert) { + QUnit.test('Navigate buttons with rtl', function(assert) { const $pager = $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1517,7 +1533,7 @@ function() { assert.equal(instance.option('pageIndex'), 8); }); - QUnit.test.skip('dxPager render with RTL', function(assert) { + QUnit.test('dxPager render with RTL', function(assert) { const pagerElement = $('#container').dxPager({ maxPagesCount: 8, pageCount: 10, @@ -1532,18 +1548,18 @@ function() { rtlTestSample = { pageSizes: pagerElement.find('.dx-page-size').text(), - pages: $(Array.prototype.slice.call(getPagesElement(pagerElement))).text() + pages: [...getPagesElement(pagerElement).map((i, j) => $(j).text())], }; pagerInstance.option('rtlEnabled', false); ltrTestSample = { pageSizes: pagerElement.find('.dx-page-size').text(), - pages: $(getPagesElement(pagerElement)).text() + pages: [...getPagesElement(pagerElement).map((i, j) => $(j).text())], }; assert.equal(rtlTestSample.pageSizes, ltrTestSample.pageSizes, 'check that page sizes in LTR are equal to page sizes in RTL'); - assert.equal(rtlTestSample.pages, ltrTestSample.pages.reverse(), 'check that pages in LTR are equal to reversed pages in RTL'); + assert.deepEqual(rtlTestSample.pages, ltrTestSample.pages.reverse(), 'check that pages in LTR are equal to reversed pages in RTL'); }); QUnit.test('dxPager has locale appropriate aria-labels (T1102800)(T1104028)', function(assert) { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/resizeHandle.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/resizeHandle.tests.js index 74ae5fac56e7..58e32799d4e4 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/resizeHandle.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/resizeHandle.tests.js @@ -3,7 +3,6 @@ import fx from 'animation/fx'; import { isRenderer } from 'core/utils/type'; import config from 'core/config'; import pointerMock from '../../helpers/pointerMock.js'; -import { camelize } from 'core/utils/inflector'; import ResizeHandle from '__internal/ui/splitter/resize_handle'; import { name as DOUBLE_CLICK_EVENT } from 'events/double_click'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/selection.test.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/selection.test.js index 1ae6f4872048..0c30d0d2d480 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/selection.test.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/selection.test.js @@ -2378,3 +2378,375 @@ QUnit.test('filterLengthRestriction is 0', function(assert) { assert.deepEqual(selectedKeys, [1, 2, 6], 'selected keys'); }); + +QUnit.module('onSelectionChanging', { + beforeEach: function() { + this.data = [ + { id: 1, name: 'Alex', age: 15 }, + { id: 2, name: 'Dan', age: 16 }, + { id: 3, name: 'Vadim', age: 17 }, + { id: 4, name: 'Dmitry', age: 18 }, + { id: 5, name: 'Sergey', age: 18 }, + { id: 6, name: 'Kate', age: 20 }, + { id: 7, name: 'Dan', age: 21 } + ]; + + this.dataSource = createDataSource(this.data, {}, {}); + this.firstThreeItems = this.data.slice(0, 3); + this.basicSelectionConfig = { + key: () => { + const store = this.dataSource.store(); + return store && store.key(); + }, + keyOf: (item) => { + const store = this.dataSource.store(); + return store && store.keyOf(item); + }, + dataFields: () => { + return this.dataSource.select(); + }, + plainItems: () => { + return this.dataSource.items(); + }, + }; + } +}, () => { + QUnit.test('should be called with correct parameters', function(assert) { + const selectionChangingHandler = sinon.spy((args) => { + assert.deepEqual(args.selectedItems, this.firstThreeItems, 'selectedItems is correct'); + assert.deepEqual(args.selectedItemKeys, this.firstThreeItems, 'selectedItemsKeys is correct'); + assert.deepEqual(args.addedItemKeys, this.firstThreeItems, 'addedItemKeys is correct'); + assert.deepEqual(args.addedItems, this.firstThreeItems, 'addedItems is correct'); + assert.deepEqual(args.removedItemKeys, [], 'removedItemKeys is correct'); + assert.deepEqual(args.removedItems, [], 'removedItems is correct'); + assert.strictEqual(args.cancel, false, 'cancel is correct'); + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + selection + .selectedItemKeys(this.firstThreeItems) + .then((appliedSelectedItems) => { + assert.deepEqual(appliedSelectedItems, this.firstThreeItems, 'deferred is resolved with correct parameters'); + }); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged is called once'); + assert.deepEqual(selection.getSelectedItemKeys(), this.firstThreeItems, 'selectedItemKeys is updated correctly'); + assert.strictEqual(selection.isItemSelected(this.data[0]), true, 'isItemSelected returns true'); + }); + + QUnit.test('cancelling should prevent selectedItems change and selectionChanged raise', function(assert) { + const selectionChangingHandler = sinon.spy(function(e) { + e.cancel = true; + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + const selectionDeferred = selection.selectedItemKeys(this.firstThreeItems); + + assert.strictEqual(selectionDeferred.state(), 'rejected', 'selection deferred is rejected'); + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not called'); + assert.deepEqual(selection.getSelectedItems(), [], 'selectedItems is not updated'); + assert.deepEqual(selection.getSelectedItemKeys(), [], 'selectedItemKeys is not updated'); + assert.strictEqual(selection.isItemSelected(this.data[0]), false, 'isItemSelected returns false'); + }); + + QUnit.test('cancelling should prevent selectedItems change and selectionChanged raise (e.cancel=promise)', function(assert) { + const done = assert.async(); + + const selectionChangingHandler = sinon.spy(function(e) { + e.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }); + }); + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + const selectionDeferred = selection.selectedItemKeys(this.firstThreeItems); + + setTimeout(() => { + assert.strictEqual(selectionDeferred.state(), 'rejected', 'selection deferred is rejected'); + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not called'); + assert.deepEqual(selection.getSelectedItems(), [], 'selectedItems is not updated'); + assert.deepEqual(selection.getSelectedItemKeys(), [], 'selectedItemKeys is not updated'); + assert.strictEqual(selection.isItemSelected(this.data[0]), false, 'isItemSelected returns false'); + done(); + }); + }); + + QUnit.test('requests should be ignored while previous request is not processed', function(assert) { + const done = assert.async(); + const delay = 100; + + const selectionChangingHandler = sinon.spy(function(e) { + e.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, delay); + }); + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + const selectionDeferred = selection.selectedItemKeys(this.firstThreeItems); + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + + setTimeout(() => { + const secondSelectionDeferred = selection.selectedItemKeys([this.data[0]]); + assert.strictEqual(secondSelectionDeferred.state(), 'rejected', 'second selection request is immediately rejected'); + assert.strictEqual(selectionDeferred.state(), 'pending', 'first request is still in progress'); + + setTimeout(() => { + assert.strictEqual(selectionDeferred.state(), 'resolved', 'first request is resolved'); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged is called once'); + assert.deepEqual(selection.getSelectedItems(), this.firstThreeItems, 'selectedItems are update correctly'); + done(); + }, delay / 2); + }, delay / 2); + }); + + QUnit.test('should have correct parameters on repeative call if previous request was canceled', function(assert) { + const selectionChangingHandler = sinon.spy((args) => { + args.cancel = true; + if(selectionChangingHandler.callCount === 2) { + assert.deepEqual(args.selectedItems, this.firstThreeItems, 'selectedItems is correct'); + assert.deepEqual(args.selectedItemKeys, this.firstThreeItems, 'selectedItemsKeys is correct'); + assert.deepEqual(args.addedItemKeys, this.firstThreeItems, 'addedItemKeys is correct'); + assert.deepEqual(args.removedItemKeys, [], 'removedItemKeys is correct'); + } + }); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler + }); + + this.dataSource.load(); + + selection.selectedItemKeys(this.data[4]); + selection.selectedItemKeys(this.firstThreeItems); + + assert.strictEqual(selectionChangingHandler.callCount, 2, 'selectionChanging is called once'); + }); + + QUnit.module('select all by one page', { + beforeEach: function() { + this.dataSource = createDataSource(this.data, {}, { paginate: true, pageSize: 3 }); + } + }, () => { + QUnit.test('should call selectionChanging with correct parameters', function(assert) { + const selectionChangingHandler = sinon.spy((args) => { + assert.deepEqual(args.selectedItems, this.firstThreeItems, 'selectedItems is correct'); + assert.deepEqual(args.selectedItemKeys, this.firstThreeItems, 'selectedItemsKeys is correct'); + assert.deepEqual(args.addedItemKeys, this.firstThreeItems, 'addedItemKeys is correct'); + assert.deepEqual(args.addedItems, this.firstThreeItems, 'addedItems is correct'); + assert.deepEqual(args.removedItemKeys, [], 'removedItemKeys is correct'); + assert.deepEqual(args.removedItems, [], 'removedItems is correct'); + assert.strictEqual(args.cancel, false, 'cancel is correct'); + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + selection.selectAll(true); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged is called once'); + assert.strictEqual(selection.getSelectAllState(true), true, 'select all is true'); + }); + + QUnit.test('selection should be canceled if e.cancel = true', function(assert) { + const selectionChangingHandler = sinon.spy((args) => { + args.cancel = true; + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + selection.selectAll(true); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not called'); + assert.strictEqual(selection.getSelectAllState(true), false, 'select all is not changed'); + }); + + QUnit.test('selection should be canceled if e.cancel is a promise resolving with true', function(assert) { + const done = assert.async(); + const selectionChangingHandler = sinon.spy((args) => { + args.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }); + }); + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + selection.selectAll(true); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called immediately'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not called until promise is resolved'); + + setTimeout(() => { + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not called'); + assert.strictEqual(selection.getSelectAllState(true), false, 'select all is not changed'); + done(); + }); + }); + + QUnit.test('selection should be applied if e.cancel is a promise resolving with false', function(assert) { + const done = assert.async(); + + const selectionChangingHandler = sinon.spy((args) => { + args.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }); + }); + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + selection.selectAll(true); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called immediately'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not called until promise is resolved'); + + setTimeout(() => { + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged is called once'); + assert.strictEqual(selection.getSelectAllState(true), true, 'select all is changed'); + done(); + }); + }); + + QUnit.test('selection should be applied if e.cancel is a promise which will be rejected', function(assert) { + const done = assert.async(); + const selectionChangingHandler = sinon.spy((args) => { + args.cancel = new Promise((resolve, reject) => { + setTimeout(() => { + reject(); + }); + }); + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + selection.selectAll(true); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called immediately'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not called until promise is resolved'); + + setTimeout(() => { + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged is called once'); + assert.strictEqual(selection.getSelectAllState(true), true, 'select all is changed'); + done(); + }); + }); + + QUnit.test('selecton requests should be ignored until previous one is not processed', function(assert) { + const done = assert.async(); + const delay = 100; + const selectionChangingHandler = sinon.spy((args) => { + args.cancel = new Promise((resolve, reject) => { + setTimeout(() => { + resolve(false); + }, delay); + }); + }); + const selectionChangedHandler = sinon.stub(); + + const selection = new Selection({ + ...this.basicSelectionConfig, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + this.dataSource.load(); + const selectionDeferred = selection.selectAll(true); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called immediately'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not called until promise is resolved'); + + setTimeout(() => { + const secondSelectionDeferred = selection.deselectAll(true); + assert.strictEqual(secondSelectionDeferred.state(), 'rejected', 'second request is immediately rejected'); + assert.strictEqual(selectionDeferred.state(), 'pending', 'first request is still in progress'); + + setTimeout(() => { + assert.strictEqual(selectionDeferred.state(), 'resolved', 'first request is resolved'); + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged is called once'); + assert.strictEqual(selection.getSelectAllState(true), true, 'select all is changed'); + + setTimeout(() => { + assert.strictEqual(selectionChangingHandler.callCount, 1, 'additional selectionChanging is not called'); + assert.strictEqual(selection.getSelectAllState(true), true, 'second selection request is ignored'); + done(); + }, delay / 2); + }, delay / 2); + }, delay / 2); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabPanel.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabPanel.tests.js index eb9a575bd941..26a956886d9c 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabPanel.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabPanel.tests.js @@ -10,6 +10,7 @@ import TabPanel from 'ui/tab_panel'; import keyboardMock from '../../helpers/keyboardMock.js'; import pointerMock from '../../helpers/pointerMock.js'; import registerKeyHandlerTestHelper from '../../helpers/registerKeyHandlerTestHelper.js'; +import translator from 'animation/translator'; QUnit.testStart(() => { @@ -43,6 +44,8 @@ const FOCUSED_DISABLED_NEXT_TAB_CLASS = 'dx-focused-disabled-next-tab'; const FOCUSED_DISABLED_PREV_TAB_CLASS = 'dx-focused-disabled-prev-tab'; const FOCUS_STATE_CLASS = 'dx-state-focused'; const TABPANEL_CONTAINER_CLASS = 'dx-tabpanel-container'; +const MULTIVIEW_ITEM_CONTAINER_CLASS = 'dx-multiview-item-container'; +const MULTIVIEW_HIDDEN_ITEM_CLASS = 'dx-multiview-item-hidden'; const TABPANEL_TABS_POSITION_CLASS = { top: 'dx-tabpanel-tabs-position-top', @@ -317,6 +320,433 @@ QUnit.module('options', { }); }); +QUnit.module('onSelectionChanging', { + beforeEach() { + fx.off = true; + this.onSelectionChangingStub = sinon.stub(); + this.onSelectionChangedStub = sinon.stub(); + this.items = [{ text: '1' }, { text: '2' }]; + + const initialOptions = { + items: this.items, + selectionMode: 'single', + onSelectionChanging: this.onSelectionChangingStub, + onSelectionChanged: this.onSelectionChangedStub, + }; + + this.init = (options) => { + this.$tabPanel = $('#tabPanel'); + this.tabPanel = this.$tabPanel + .dxTabPanel($.extend({}, initialOptions, options)) + .dxTabPanel('instance'); + this.$tabs = this.$tabPanel.find(toSelector(TABS_CLASS)); + this.tabs = this.$tabs.dxTabs('instance'); + }; + this.reinit = (options) => { + this.tabPanel.dispose(); + this.init(options); + }; + this.init(); + + this.assertSelectionNotChanged = (assert) =>{ + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'onSelectionChanging should be called once'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 0, 'onSelectionChanged should not be called'); + + assert.strictEqual(this.tabs.option('selectedIndex'), 0, 'Tabs selectedIndex should remain 0'); + assert.deepEqual(this.tabs.option('selectedItem'), this.items[0], 'Tabs selectedItem should remain the first item'); + assert.deepEqual(this.tabs.option('selectedItems'), [this.items[0]], 'Tabs selectedItems should remain the first item'); + assert.deepEqual(this.tabs.option('selectedItemKeys'), [this.items[0]], 'Tabs selectedItemKeys should remain the first key'); + assert.ok(this.$tabs.find(`.${TABS_ITEM_CLASS}`).eq(0).hasClass(SELECTED_TAB_CLASS), 'First tab had a selected class'); + + + assert.strictEqual(this.tabPanel.option('selectedIndex'), 0, 'TabPanel selectedIndex should remain 0'); + assert.deepEqual(this.tabPanel.option('selectedItem'), this.items[0], 'TabPanel selectedItem should remain the first item'); + assert.deepEqual(this.tabPanel.option('selectedItems'), [this.items[0]], 'TabPanel selectedItems should remain the first item'); + assert.deepEqual(this.tabPanel.option('selectedItemKeys'), [this.items[0]], 'TabPanel selectedItemKeys should remain the first key'); + assert.ok(this.$tabPanel.find(`.${MULTIVIEW_ITEM_CLASS}`).eq(0).hasClass(SELECTED_ITEM_CLASS), 'First multiView had a selected class'); + }; + this.assertSecondItemSelected = (assert) => { + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'onSelectionChanging should be called'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 1, 'onSelectionChanged should be called'); + + assert.strictEqual(this.tabs.option('selectedIndex'), 1, 'Tabs selectedIndex should be updated to 1'); + assert.deepEqual(this.tabs.option('selectedItem'), this.items[1], 'Tabs selectedItem should be equal to the second item'); + assert.deepEqual(this.tabs.option('selectedItems'), [this.items[1]], 'Tabs selectedItems should contain second item'); + assert.deepEqual(this.tabs.option('selectedItemKeys'), [this.items[1]], 'Tabs selectedItemKeys should contain second item'); + assert.ok(this.$tabs.find(`.${TABS_ITEM_CLASS}`).eq(1).hasClass(SELECTED_TAB_CLASS), 'Second tab had a selected class'); + + assert.strictEqual(this.tabPanel.option('selectedIndex'), 1, 'TabPanel selectedIndex should be updated to 1'); + assert.deepEqual(this.tabPanel.option('selectedItem'), this.items[1], 'TabPanel selectedItem should be equal to the second item'); + assert.deepEqual(this.tabPanel.option('selectedItems'), [this.items[1]], 'TabPanel selectedItems should contain second item'); + assert.deepEqual(this.tabPanel.option('selectedItemKeys'), [this.items[1]], 'TabPanel selectedItemKeys should contain second item'); + assert.ok(this.$tabPanel.find(`.${MULTIVIEW_ITEM_CLASS}`).eq(1).hasClass(SELECTED_ITEM_CLASS), 'Second multiView had a selected class'); + }; + }, + afterEach() { + fx.off = false; + } +}, () => { + QUnit.test('should be raised only once when tab is focused and clicked', function(assert) { + const clock = sinon.useFakeTimers(); + this.onSelectionChangingStub = sinon.spy((e) => { + e.cancel = true; + }); + + this.reinit({ + onSelectionChanging: this.onSelectionChangingStub, + focusStateEnabled: true + }); + + this.$tabPanel.trigger('focusin'); + const $item = this.$tabPanel.find(`.${MULTIVIEW_ITEM_CLASS}`).eq(1); + $item.trigger('dxpointerdown'); + $item.trigger('dxclick'); + clock.tick(10); + + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'onSelectionChanging should be called'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 0, 'onSelectionChanged is not called'); + + assert.strictEqual(this.tabPanel.option('selectedIndex'), 0, 'tabPanel selected index is not changed'); + clock.restore(); + }); + + QUnit.test('should be ignored if previous request is not processed yet', function(assert) { + const done = assert.async(); + const delay = 300; + this.onSelectionChangingStub = sinon.spy((e) => { + e.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, delay); + }); + }); + + const items = [{ text: '1' }, { text: '2' }, { text: '3' }]; + this.reinit({ + onSelectionChanging: this.onSelectionChangingStub, + items + }); + + const $items = this.$tabPanel.find(`.${TABS_ITEM_CLASS}`); + + $items.eq(2).trigger('dxclick'); + $items.eq(0).trigger('dxclick'); + $items.eq(1).trigger('dxclick'); + + setTimeout(() => { + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'onSelectionChanging should be called'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 1, 'onSelectionChanged should be called'); + + assert.strictEqual(this.tabs.option('selectedIndex'), 2, 'Tabs selectedIndex should be updated to 2'); + assert.deepEqual(this.tabs.option('selectedItem'), items[2], 'Tabs selectedItem should be equal to the third item'); + assert.deepEqual(this.tabs.option('selectedItems'), [items[2]], 'Tabs selectedItems should contain third item'); + assert.deepEqual(this.tabs.option('selectedItemKeys'), [items[2]], 'Tabs selectedItemKeys should contain third item'); + + assert.strictEqual(this.tabPanel.option('selectedIndex'), 2, 'TabPanel selectedIndex should be updated to 2'); + assert.deepEqual(this.tabPanel.option('selectedItem'), items[2], 'TabPanel selectedItem should be equal to the third item'); + assert.deepEqual(this.tabPanel.option('selectedItems'), [items[2]], 'TabPanel selectedItems should contain third item'); + assert.deepEqual(this.tabPanel.option('selectedItemKeys'), [items[2]], 'TabPanel selectedItemKeys should contain third item'); + + const $multiViewItems = this.$tabPanel.find(`.${MULTIVIEW_ITEM_CLASS}`); + assert.ok($multiViewItems.eq(2).hasClass(SELECTED_ITEM_CLASS), 'second multiview item is selected'); + assert.notOk($multiViewItems.eq(2).hasClass(MULTIVIEW_HIDDEN_ITEM_CLASS), 'second multiview item is visible'); + + done(); + }, delay); + }); + + QUnit.test('tabPanel selectOnFocus should be false to not raise excess selectionChanging', function(assert) { + assert.strictEqual(this.tabPanel.option('selectOnFocus'), false, 'selectOnFocus = false'); + }); + + + QUnit.module('after keyboard navigation', () => { + QUnit.test('should keep new item focused even if selection is cancelled', function(assert) { + this.onSelectionChangingStub = sinon.spy((e) => { + e.cancel = true; + }); + + this.reinit({ + onSelectionChanging: this.onSelectionChangingStub, + focusStateEnabled: true + }); + + this.tabPanel.focus(); + + const keyboard = keyboardMock(this.$tabs); + keyboard.press('right'); + + this.assertSelectionNotChanged(assert); + + const $secondTab = this.$tabs.find(`.${TABS_ITEM_CLASS}`).eq(1); + assert.ok($secondTab.hasClass(FOCUS_STATE_CLASS), 'focus is moved to the second tab'); + }); + }); + + QUnit.module('after multiView swipe', () => { + QUnit.test('should cancel selection if e.cancel = true', function(assert) { + this.onSelectionChangingStub = sinon.spy((e) => { + e.cancel = true; + }); + + this.reinit({ + onSelectionChanging: this.onSelectionChangingStub, + swipeEnabled: true + }); + + const pointer = pointerMock(this.$tabPanel); + pointer.start().swipeStart().swipe(-0.5).swipeEnd(-1); + + this.assertSelectionNotChanged(assert); + + const $itemContainer = this.$tabPanel.find(`.${MULTIVIEW_ITEM_CONTAINER_CLASS}`); + assert.strictEqual(translator.locate($itemContainer).left, 0, 'container was not swiped'); + }); + + QUnit.test('should cancel selection if e.cancel is a promise resolving with true', function(assert) { + const done = assert.async(); + + this.onSelectionChangingStub = sinon.spy((e) => { + e.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }); + }); + }); + + this.reinit({ + onSelectionChanging: this.onSelectionChangingStub, + swipeEnabled: true + }); + + const $itemContainer = this.$tabPanel.find(`.${MULTIVIEW_ITEM_CONTAINER_CLASS}`); + + const pointer = pointerMock(this.$tabPanel); + pointer.start().swipeStart().swipe(-0.5).swipeEnd(-1); + + assert.notEqual(translator.locate($itemContainer).left, 0, 'container scroll is not restored immediately'); + + setTimeout(() => { + setTimeout(() => { + this.assertSelectionNotChanged(assert); + + assert.strictEqual(translator.locate($itemContainer).left, 0, 'container scroll is restored'); + done(); + }); + }); + }); + + QUnit.test('should apply selection if e.cancel is a promise resolving with false', function(assert) { + const done = assert.async(); + + this.onSelectionChangingStub = sinon.spy((e) => { + e.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }); + }); + }); + + this.reinit({ + onSelectionChanging: this.onSelectionChangingStub, + swipeEnabled: true + }); + + const $itemContainer = this.$tabPanel.find(`.${MULTIVIEW_ITEM_CONTAINER_CLASS}`); + + const pointer = pointerMock(this.$tabPanel); + pointer.start().swipeStart().swipe(-0.5).swipeEnd(-1); + + assert.notEqual(translator.locate($itemContainer).left, 0, 'container scroll is not restored immediately'); + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'selectionChanging is called immediately'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 0, 'selectionChanged is not called until promise is resolved'); + + setTimeout(() => { + setTimeout(() => { + this.assertSecondItemSelected(assert); + + assert.strictEqual(translator.locate($itemContainer).left, 0, 'container scroll is restored'); + done(); + }); + }); + }); + + QUnit.test('should apply selection if e.cancel is a promise which rejects', function(assert) { + const done = assert.async(); + + this.onSelectionChangingStub = sinon.spy((e) => { + e.cancel = new Promise((resolve, reject) => { + setTimeout(() => { + reject(); + }); + }); + }); + + this.reinit({ + onSelectionChanging: this.onSelectionChangingStub, + swipeEnabled: true + }); + + const $itemContainer = this.$tabPanel.find(`.${MULTIVIEW_ITEM_CONTAINER_CLASS}`); + + const pointer = pointerMock(this.$tabPanel); + pointer.start().swipeStart().swipe(-0.5).swipeEnd(-1); + + assert.notEqual(translator.locate($itemContainer).left, 0, 'container scroll is not restored immediately'); + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'selectionChanging is called immediately'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 0, 'selectionChanged is not called until promise is resolved'); + + setTimeout(() => { + setTimeout(() => { + this.assertSecondItemSelected(assert); + + assert.strictEqual(translator.locate($itemContainer).left, 0, 'container scroll is restored'); + + done(); + }); + }); + }); + + QUnit.test('should apply the selection if e.cancel is not modified', function(assert) { + this.reinit({ + swipeEnabled: true + }); + + const pointer = pointerMock(this.$tabPanel); + pointer.start().swipeStart().swipe(-0.5).swipeEnd(-1); + + const $itemContainer = this.$tabPanel.find(`.${MULTIVIEW_ITEM_CONTAINER_CLASS}`); + + this.assertSecondItemSelected(assert); + + assert.strictEqual(translator.locate($itemContainer).left, 0, 'container scroll is restored'); + }); + }); + + QUnit.module('should cancel selection', () => { + QUnit.test('when it sets cancel=true in initial config', function(assert) { + this.onSelectionChangingStub = sinon.spy(function(e) { + e.cancel = true; + }); + this.reinit({ + onSelectionChanging: this.onSelectionChangingStub, + }); + const $item = this.$tabPanel.find(`.${TABPANEL_TABS_ITEM_CLASS}`).eq(1); + $item.trigger('dxclick'); + + this.assertSelectionNotChanged(assert); + }); + + QUnit.test('when it sets cancel=true in runtime', function(assert) { + this.onSelectionChangingStub = sinon.spy(function(e) { + e.cancel = true; + }); + this.tabPanel.option({ + onSelectionChanging: this.onSelectionChangingStub, + }); + + const $item = this.$tabPanel.find(`.${TABPANEL_TABS_ITEM_CLASS}`).eq(1); + $item.trigger('dxclick'); + + this.assertSelectionNotChanged(assert); + }); + + QUnit.test('when it sets cancel to promise in initial config and resolves it with true', function(assert) { + const done = assert.async(); + this.onSelectionChangingStub = sinon.spy(function(e) { + e.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }); + }); + }); + this.reinit({ + onSelectionChanging: this.onSelectionChangingStub, + }); + const $item = this.$tabPanel.find(`.${TABPANEL_TABS_ITEM_CLASS}`).eq(1); + $item.trigger('dxclick'); + + assert.strictEqual(this.tabs.option('selectedIndex'), 0, 'selectedIndex is not changed until promise is resolved'); + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'onSelectionChanging should be called immediatelly'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 0, 'onSelectionChanged should not be called until promise is resolved'); + + setTimeout(() => { + setTimeout(() => { + this.assertSelectionNotChanged(assert); + done(); + }); + }); + }); + }); + + QUnit.module('should apply new selection', () => { + QUnit.test('when e.cancel is not modified', function(assert) { + const $item = this.$tabPanel.find(`.${TABPANEL_TABS_ITEM_CLASS}`).eq(1); + $item.trigger('dxclick'); + + assert.strictEqual(this.onSelectionChangingStub.getCall(0).args[0].cancel, false, 'e.cancel should be set to false'); + + this.assertSecondItemSelected(assert); + }); + + QUnit.test('when it sets e.cancel to a promise resolved with false', function(assert) { + const done = assert.async(); + + this.onSelectionChangingStub = sinon.spy(function(e) { + e.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }); + }); + }); + + this.tabPanel.option('onSelectionChanging', this.onSelectionChangingStub); + + const $item = this.$tabPanel.find(`.${TABPANEL_TABS_ITEM_CLASS}`).eq(1); + + $item.trigger('dxclick'); + assert.strictEqual(this.tabs.option('selectedIndex'), 0, 'selectedIndex is not changed until promise is resolved'); + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'onSelectionChanging should be called immediatelly'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 0, 'onSelectionChanged should not be called until promise is resolved'); + + setTimeout(() => { + setTimeout(() => { + this.assertSecondItemSelected(assert); + done(); + }); + }); + }); + + QUnit.test('when it sets e.cancel to a rejected promise', function(assert) { + const done = assert.async(); + + this.onSelectionChangingStub = sinon.spy((e) => { + e.cancel = new Promise((resolve, reject) => { + setTimeout(() => { + reject('Cancellation error'); + }); + }, 0); + }); + + this.tabPanel.option('onSelectionChanging', this.onSelectionChangingStub); + + const $item = this.$tabPanel.find(`.${TABPANEL_TABS_ITEM_CLASS}`).eq(1); + $item.trigger('dxclick'); + + assert.strictEqual(this.tabs.option('selectedIndex'), 0, 'selectedIndex is not changed until promise is resolved'); + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'onSelectionChanging should be called after click'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 0, 'onSelectionChanged should not be called before promise resolves'); + + setTimeout(() => { + setTimeout(() => { + this.assertSecondItemSelected(assert); + done(); + }); + }); + }); + }); +}); + QUnit.module('action handlers', { beforeEach() { this.clock = sinon.useFakeTimers(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.tests.js index efe5e5487d5d..810cfc741af9 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.tests.js @@ -37,6 +37,7 @@ QUnit.testStart(function() { const TABS_ITEM_CLASS = 'dx-tab'; const TAB_SELECTED_CLASS = 'dx-tab-selected'; +const FOCUS_STATE_CLASS = 'dx-state-focused'; const TABS_SCROLLABLE_CLASS = 'dx-tabs-scrollable'; const TABS_ORIENTATION_CLASS = { vertical: 'dx-tabs-vertical', @@ -151,7 +152,7 @@ QUnit.module('General', () => { assert.equal(tabsInstance.option('selectedIndex'), 2); }); - QUnit.test('dxpointerup event should call changing active tab', function(assert) { + QUnit.test('dxpointerup event should change focused tab', function(assert) { const clock = sinon.useFakeTimers(); const $tabs = $('#tabs').dxTabs({ @@ -163,11 +164,10 @@ QUnit.module('General', () => { try { $secondTab.trigger('dxpointerdown'); clock.tick(10); - assert.strictEqual($secondTab.hasClass(TAB_SELECTED_CLASS), false); - + assert.strictEqual($secondTab.hasClass(FOCUS_STATE_CLASS), false); $secondTab.trigger('dxpointerup'); clock.tick(10); - assert.strictEqual($secondTab.hasClass(TAB_SELECTED_CLASS), true); + assert.strictEqual($secondTab.hasClass(FOCUS_STATE_CLASS), true); } finally { clock.restore(); } @@ -520,10 +520,12 @@ QUnit.module('Tab select action', () => { const $item = this.$tabs.find(`.${TABS_ITEM_CLASS}`).eq(1); + $item.trigger('dxclick'); + + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'onSelectionChanging is called immediately after click'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 0, 'onSelectionChanged is not called yet'); assert.strictEqual(this.tabs.option('selectedIndex'), 0, 'initial selectedIndex should be 0'); - assert.strictEqual(this.onSelectionChangingStub.callCount, 0, 'onSelectionChanging should not be called initially'); - $item.trigger('dxclick'); setTimeout(() => { this.assertSelectionNotChanged(assert); @@ -557,11 +559,12 @@ QUnit.module('Tab select action', () => { const $item = this.$tabs.find(`.${TABS_ITEM_CLASS}`).eq(1); - assert.strictEqual(this.tabs.option('selectedIndex'), 0, 'initial selectedIndex should be 0'); - assert.strictEqual(this.onSelectionChangingStub.callCount, 0, 'onSelectionChanging should not be called initially'); - $item.trigger('dxclick'); + assert.strictEqual(this.onSelectionChangingStub.callCount, 1, 'onSelectionChanging is called immediately after click'); + assert.strictEqual(this.onSelectionChangedStub.callCount, 0, 'onSelectionChanged is not called yet'); + assert.strictEqual(this.tabs.option('selectedIndex'), 0, 'initial selectedIndex should be 0'); + setTimeout(() => { this.assertSecondItemSelected(assert); done(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui/collectionWidgetParts/editingTests.js b/packages/devextreme/testing/tests/DevExpress.ui/collectionWidgetParts/editingTests.js index 72baa692b634..c381f42ac8db 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui/collectionWidgetParts/editingTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui/collectionWidgetParts/editingTests.js @@ -4,6 +4,7 @@ import { DataSource } from 'data/data_source/data_source'; import ArrayStore from 'data/array_store'; import CustomStore from 'data/custom_store'; import executeAsyncMock from '../../../helpers/executeAsyncMock.js'; +import keyboardMock from '../../../helpers/keyboardMock.js'; const ITEM_CLASS = 'dx-item'; const ITEM_SELECTED_CLASS = `${ITEM_CLASS}-selected`; @@ -65,6 +66,25 @@ module('onSelectionChanging event', () => { }); }); + test('should not be raised on selected item click if previous selection was cancelled', function(assert) { + const selectionChangingStub = sinon.spy((args) => { + args.cancel = true; + }); + const $element = $('#cmp'); + + const instance = new TestComponent($element, { + items: [0, 1], + selectionMode: 'single', + selectedIndex: 0, + onSelectionChanging: selectionChangingStub + }); + + instance.selectItem(1); + instance.selectItem(0); + + assert.strictEqual(selectionChangingStub.callCount, 1, 'selectionChanging is raised only once'); + }); + module('should be triggered on selection change', () => { test('if is subscribed using "on" method', function(assert) { const selectionChangingHandler = sinon.stub(); @@ -111,6 +131,170 @@ module('onSelectionChanging event', () => { }); }); + QUnit.test('should be raised only once when selectOnFocus=true and item is clicked', function(assert) { + const clock = sinon.useFakeTimers(); + const selectionChangingHandler = sinon.spy((e) => { + e.cancel = true; + }); + const selectionChangedHandler = sinon.stub(); + + const $element = $('#cmp'); + const instance = new TestComponent($element, { + items: [0, 1, 2, 3], + selectionMode: 'multiple', + selectedIndex: 0, + focusStateEnabled: true, + selectOnFocus: true, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + const $items = $(instance.itemElements()); + const $item = $items.eq(1); + + $item.trigger('pointerdown'); + $item.trigger('dxclick'); + clock.tick(); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging should be raised once'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged should not be raised'); + assert.strictEqual(instance.option('selectedIndex'), 0, 'selectedIndex should be remain unchanged'); + + clock.restore(); + }); + + QUnit.test('should not be raised on component focusin', function(assert) { + const clock = sinon.useFakeTimers(); + const selectionChangingHandler = sinon.spy((e) => { + e.cancel = true; + }); + const selectionChangedHandler = sinon.stub(); + + const $element = $('#cmp'); + const instance = new TestComponent($element, { + items: [0, 1, 2, 3], + selectionMode: 'multiple', + selectedIndex: 0, + focusStateEnabled: true, + selectOnFocus: true, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + const $items = $(instance.itemElements()); + const $item = $items.eq(1); + + instance.option('focusedElement', $item.get(0)); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called because of item focusing'); + + instance.focus(); + clock.tick(); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is not called after component focusin'); + clock.restore(); + }); + + QUnit.module('when previous selection request is not processed yet', () => { + QUnit.test('should be ignored, previous request is applied', function(assert) { + const done = assert.async(); + + const delay = 200; + + const selectionChangingHandler = sinon.spy((e) => { + e.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, delay); + }); + }); + const selectionChangedHandler = sinon.stub(); + + const $element = $('#cmp'); + const instance = new TestComponent($element, { + items: [0, 1, 2, 3], + selectionMode: 'single', + selectedIndex: 0, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + assert.strictEqual(instance.option('selectedIndex'), 0, 'initially selectedIndex should be 0'); + + instance.selectItem(1); + + setTimeout(() => { + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging should be raised once'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged should not be raised'); + assert.strictEqual(instance.option('selectedIndex'), 0, 'selectedIndex should be remain unchanged'); + + const secondSelectionDeferred = instance.selectItem(2); + assert.strictEqual(secondSelectionDeferred.state(), 'rejected', 'second selection request is rejected'); + + setTimeout(() => { + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged is raised'); + assert.strictEqual(instance.option('selectedIndex'), 1, 'selectedIndex is changed'); + + setTimeout(() => { + assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged is raised only once'); + assert.strictEqual(instance.option('selectedIndex'), 1, 'selectedIndex is not updated after second request timeout'); + done(); + }, delay / 2); + }, delay / 2); + }, delay / 2); + }); + + QUnit.test('should be ignored, previous request is canceled', function(assert) { + const done = assert.async(); + + const delay = 200; + + const selectionChangingHandler = sinon.spy((e) => { + e.cancel = new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }, delay); + }); + }); + const selectionChangedHandler = sinon.stub(); + + const $element = $('#cmp'); + const instance = new TestComponent($element, { + items: [0, 1, 2, 3], + selectionMode: 'single', + selectedIndex: 0, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + assert.strictEqual(instance.option('selectedIndex'), 0, 'initially selectedIndex should be 0'); + + instance.selectItem(1); + + setTimeout(() => { + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging should be raised once'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged should not be raised'); + assert.strictEqual(instance.option('selectedIndex'), 0, 'selectedIndex should be remain unchanged'); + + const secondSelectionDeferred = instance.selectItem(2); + assert.strictEqual(secondSelectionDeferred.state(), 'rejected', 'second selection request is rejected'); + + setTimeout(() => { + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging is called once'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not raised'); + assert.strictEqual(instance.option('selectedIndex'), 0, 'selectedIndex is not changed'); + + setTimeout(() => { + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged is not raised after second request timeout'); + assert.strictEqual(instance.option('selectedIndex'), 0, 'selectedIndex is not updated after second request timeout'); + done(); + }, delay / 2); + }, delay / 2); + }, delay / 2); + }); + }); + QUnit.module('should cancel selection change', () => { QUnit.test('if e.cancel=true', function(assert) { const selectionChangingHandler = sinon.spy((e) => { @@ -136,6 +320,38 @@ module('onSelectionChanging event', () => { assert.strictEqual(instance.option('selectedIndex'), 0, 'selectedIndex should be remain unchanged'); }); + QUnit.test('after focusing if e.cancel=true and selectOnFocus=true', function(assert) { + const clock = sinon.useFakeTimers(); + const selectionChangingHandler = sinon.spy((e) => { + e.cancel = true; + }); + const selectionChangedHandler = sinon.stub(); + + const $element = $('#cmp'); + const instance = new TestComponent($element, { + items: [0, 1, 2, 3], + selectionMode: 'multiple', + selectedIndex: 0, + focusStateEnabled: true, + selectOnFocus: true, + onSelectionChanging: selectionChangingHandler, + onSelectionChanged: selectionChangedHandler + }); + + const keyboard = keyboardMock($element); + + $element.trigger('focusin'); + keyboard.keyDown('right'); + + clock.tick(); + + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging should be raised once'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged should not be raised'); + assert.strictEqual(instance.option('selectedIndex'), 0, 'selectedIndex should be remain unchanged'); + + clock.restore(); + }); + QUnit.test('if e.cancel is a promise resolved with true', function(assert) { const done = assert.async(); @@ -214,10 +430,12 @@ module('onSelectionChanging event', () => { onSelectionChanged: selectionChangedHandler }); - assert.strictEqual(instance.option('selectedIndex'), 0, 'initially selectedIndex should be 0'); - instance.selectItem(1); + assert.strictEqual(instance.option('selectedIndex'), 0, 'initially selectedIndex should be 0'); + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging should be raised immediately after click'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged should not be raised until promise resolves'); + setTimeout(() => { assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging should be raised once'); assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged should be raised once'); @@ -247,10 +465,12 @@ module('onSelectionChanging event', () => { onSelectionChanged: selectionChangedHandler }); - assert.strictEqual(instance.option('selectedIndex'), 0, 'initially selectedIndex should be 0'); - instance.selectItem(1); + assert.strictEqual(instance.option('selectedIndex'), 0, 'initially selectedIndex should be 0'); + assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging should be raised immediately after click'); + assert.strictEqual(selectionChangedHandler.callCount, 0, 'selectionChanged should not be raised until promise resolves'); + setTimeout(() => { assert.strictEqual(selectionChangingHandler.callCount, 1, 'selectionChanging should be raised once'); assert.strictEqual(selectionChangedHandler.callCount, 1, 'selectionChanged should be raised once'); diff --git a/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js b/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js index b50e22d27bbd..c77881bb8a22 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js @@ -14,6 +14,12 @@ import Accordion from 'ui/accordion'; import Button from 'ui/button'; import ColorBox from 'ui/color_box'; import Chat from 'ui/chat'; +import ChatHeader from '__internal/ui/chat/chat_header'; +import ChatAvatar from '__internal/ui/chat/chat_avatar'; +import ChatMessageBox from '__internal/ui/chat/chat_message_box'; +import ChatMessageBubble from '__internal/ui/chat/chat_message_bubble'; +import ChatMessageGroup from '__internal/ui/chat/chat_message_group'; +import ChatMessageList from '__internal/ui/chat/chat_message_list'; import DataGrid from 'ui/data_grid'; import DateBox from 'ui/date_box'; import DateRangeBox from 'ui/date_range_box'; @@ -1341,6 +1347,48 @@ testComponentDefaults(Chat, } ); +testComponentDefaults(ChatAvatar, + {}, + { + name: '', + } +); + +testComponentDefaults(ChatHeader, + {}, + { + title: '', + } +); + +testComponentDefaults(ChatMessageBox, + {}, + { + onMessageSend: undefined, + } +); + +testComponentDefaults(ChatMessageBubble, + {}, + { + text: '', + } +); + +testComponentDefaults(ChatMessageGroup, + {}, + { + alignment: 'start', + } +); + +testComponentDefaults(ChatMessageList, + {}, + { + currentUserId: '', + } +); + testComponentDefaults(List, { platform: devices.current().platform }, { diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 6c5466bcc213..80039db74b04 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -9415,7 +9415,12 @@ declare module DevExpress.ui { /** * [descr:dxChat] */ - export class dxChat extends Widget {} + export class dxChat extends Widget { + /** + * [descr:dxChat.renderMessage(message)] + */ + renderMessage(message: DevExpress.ui.dxChat.Message): void; + } module dxChat { /** * [descr:_ui_chat_DisposingEvent] @@ -9435,12 +9440,15 @@ declare module DevExpress.ui { /** * [descr:_ui_chat_MessageSendEvent] */ - export type MessageSendEvent = DevExpress.events.Cancelable & - DevExpress.events.NativeEventInfo< - dxChat, - KeyboardEvent | PointerEvent | MouseEvent | TouchEvent - > & - Message; + export type MessageSendEvent = DevExpress.events.NativeEventInfo< + dxChat, + KeyboardEvent | PointerEvent | MouseEvent | TouchEvent + > & { + /** + * [descr:MessageSendEvent.message] + */ + readonly message?: Message; + }; /** * [descr:_ui_chat_OptionChangedEvent] */ @@ -11632,6 +11640,10 @@ declare module DevExpress.ui { * [descr:dxDataGridOptions.selection.deferred] */ deferred?: boolean; + /** + * [descr:dxDataGridOptions.selection.sensitivity] + */ + sensitivity?: SelectionSensitivity; /** * [descr:dxDataGridOptions.selection.selectAllMode] */ @@ -11654,6 +11666,7 @@ declare module DevExpress.ui { TKey = any > = DevExpress.events.EventInfo> & DevExpress.common.grids.SelectionChangedInfo; + export type SelectionSensitivity = 'base' | 'accent' | 'case' | 'variant'; /** * [descr:dxDataGridSortByGroupSummaryInfoItem] */ diff --git a/packages/testcafe-models/dataGrid/index.ts b/packages/testcafe-models/dataGrid/index.ts index 8f8e04973a61..b7291628f753 100644 --- a/packages/testcafe-models/dataGrid/index.ts +++ b/packages/testcafe-models/dataGrid/index.ts @@ -1,11 +1,12 @@ import { ClientFunction, Selector } from 'testcafe'; import DataGridInstance from 'devextreme/ui/data_grid'; +import type { SelectionSensitivity } from 'devextreme/ui/data_grid'; import Widget from '../internal/widget'; import Toolbar from '../toolbar'; import DataRow from './data/row'; import GroupRow from './groupRow'; import FilterPanel from './filter/panel'; -import Pager from './pager'; +import Pager from '../pager'; import EditForm from './editForm'; import HeaderPanel from './headers/panel'; import DataCell from './data/cell'; @@ -788,4 +789,16 @@ export default class DataGrid extends Widget { getSummaryTotalElement(nth = 0): Selector { return this.element().find(`.${CLASS.summaryTotal}`).nth(nth); } + + apiChangeSensitivity( + sensitivity: SelectionSensitivity, + ): Promise { + const { getInstance } = this; + return ClientFunction( + () => { + (getInstance() as DataGridInstance).option('selection.sensitivity', sensitivity); + }, + { dependencies: { getInstance, sensitivity } }, + )(); + } } diff --git a/packages/testcafe-models/dataGrid/pager.ts b/packages/testcafe-models/pager/index.ts similarity index 78% rename from packages/testcafe-models/dataGrid/pager.ts rename to packages/testcafe-models/pager/index.ts index 31303ee715e5..08201c12a878 100644 --- a/packages/testcafe-models/dataGrid/pager.ts +++ b/packages/testcafe-models/pager/index.ts @@ -2,7 +2,9 @@ import { Selector } from 'testcafe'; import NavPage from './navPage'; import FocusableElement from '../internal/focusable'; -import { SelectableElement } from './SelectableElement'; +import { SelectableElement } from './selectableElement'; +import Widget from '../internal/widget'; +import { WidgetName } from '../types'; const CLASS = { pagerPageSize: 'dx-page-size', @@ -17,9 +19,12 @@ const CLASS = { numberBox: 'dx-numberbox', overlayContent: 'dx-overlay-content', + focusedState: 'dx-state-focused', }; -export default class Pager extends FocusableElement { +export default class Pager extends Widget { + getName(): WidgetName { return 'dxPager'; } + getPageSize(index: number): SelectableElement { return new SelectableElement(this.element .find(`.${CLASS.pagerPageSize}`) @@ -42,7 +47,6 @@ export default class Pager extends FocusableElement { return this.element.find(`.${CLASS.pagerPageSizes} .${CLASS.select}`); } - // eslint-disable-next-line class-methods-use-this getPopupPageSizes(): Selector { return Selector(`.${CLASS.overlayContent} .${CLASS.item}`); } @@ -51,7 +55,11 @@ export default class Pager extends FocusableElement { return this.element.find(`.${CLASS.pagerPageIndex}.${CLASS.numberBox}`); } - get infoText(): Selector { + getInfoText(): Selector { return this.element.find(`.${CLASS.info}`); } + + hasFocusedState(): Promise { + return this.element.hasClass(CLASS.focusedState); + } } diff --git a/packages/testcafe-models/dataGrid/navPage.ts b/packages/testcafe-models/pager/navPage.ts similarity index 80% rename from packages/testcafe-models/dataGrid/navPage.ts rename to packages/testcafe-models/pager/navPage.ts index 1ebbd70acdfa..95222c4249ca 100644 --- a/packages/testcafe-models/dataGrid/navPage.ts +++ b/packages/testcafe-models/pager/navPage.ts @@ -1,4 +1,4 @@ -import { SelectableElement } from './SelectableElement'; +import { SelectableElement } from './selectableElement'; const CLASS = { pagerPage: 'dx-page', diff --git a/packages/testcafe-models/dataGrid/SelectableElement.ts b/packages/testcafe-models/pager/selectableElement.ts similarity index 100% rename from packages/testcafe-models/dataGrid/SelectableElement.ts rename to packages/testcafe-models/pager/selectableElement.ts