diff --git a/.babelrc b/.babelrc index 7230e2b9857e..a415eedc217c 100644 --- a/.babelrc +++ b/.babelrc @@ -1,7 +1,12 @@ { "presets": ["@babel/preset-env"], - "plugins": ["transform-es2015-modules-commonjs", "add-module-exports", - "@babel/plugin-proposal-nullish-coalescing-operator", "@babel/plugin-proposal-optional-chaining" + "plugins": [ + "transform-es2015-modules-commonjs", + "add-module-exports", + "@babel/plugin-proposal-nullish-coalescing-operator", + "@babel/plugin-proposal-optional-chaining", + ["transform-react-jsx", { "pragma": "Preact.h" }], + "transform-object-assign" ], "ignore": ["**/*.json", "**/sinon.js"] -} \ No newline at end of file +} diff --git a/.drone.yml b/.drone.yml index 2266e4e1efb3..20d8eee69ddd 100644 --- a/.drone.yml +++ b/.drone.yml @@ -43,6 +43,7 @@ matrix: - { TARGET: test, CONSTEL: ui.scheduler, TZ: 'Japan' } - { TARGET: test, CONSTEL: ui.scheduler, TZ: 'Australia/ACT' } - { TARGET: test, CONSTEL: viz } + - { TARGET: test, CONSTEL: renovation } - { TARGET: test, PERF: true, JQUERY: true, NO_HEADLESS: true } - { TARGET: test, MOBILE_UA: ios9, CONSTEL: ui } - { TARGET: test, MOBILE_UA: ios9, CONSTEL: ui.editors, NO_HEADLESS: true } @@ -61,9 +62,11 @@ matrix: - { TARGET: test, BROWSER: firefox, JQUERY: true, CONSTEL: ui.grid } - { TARGET: test, BROWSER: firefox, JQUERY: true, CONSTEL: ui.scheduler } - { TARGET: test, BROWSER: firefox, JQUERY: true, CONSTEL: viz } + - { TARGET: test, BROWSER: firefox, JQUERY: true, CONSTEL: renovation } - { TARGET: test_functional, COMPONENT: dataGrid, QUARANTINE_MODE: true } - { TARGET: test_functional, COMPONENT: scheduler, QUARANTINE_MODE: true } - { TARGET: test_functional, COMPONENT: editors } - { TARGET: test_functional, COMPONENT: navigation } - { TARGET: test_themebuilder } + - { TARGET: test_jest } - { TARGET: test_scss } diff --git a/.eslintignore b/.eslintignore index 22face29318a..f0b7ca327162 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,5 +2,11 @@ artifacts/* js/viz/docs/* node_modules/* testing/helpers/sinon/* +*.p.js +*.j.js +*.p.d.ts +*.j.d.ts +playground/* themebuilder/data/metadata/* themebuilder-scss/**/* +js/bundles/dx.custom.js diff --git a/.eslintrc b/.eslintrc index 250e1850a88c..d7a17480456f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,17 +2,17 @@ "env": { "es6": true }, - "parser": "babel-eslint", + "parser": "@typescript-eslint/parser", "parserOptions": { + "createDefaultProgram": true, + "project": "./tsconfig.json", "ecmaVersion": 6, "sourceType": "module", "ecmaFeatures": { - "globalReturn": true + "globalReturn": true, + "jsx": true } }, - "plugins": [ - "spellcheck" - ], "globals": { "setInterval": true, "setTimeout": true, @@ -22,7 +22,7 @@ "module": true, "exports": true }, - "extends": "eslint:recommended", + "extends": ["eslint:recommended", "devextreme/spell-check"], "rules": { "block-spacing": "error", "comma-spacing": "error", @@ -51,7 +51,7 @@ "no-new-func": "error", "no-eval": "error", "no-undef-init": "error", - "no-unused-vars": ["error", { "args": "none" }], + "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }], "no-extend-native": "error", "no-alert": "error", "no-console": "error", @@ -89,436 +89,10 @@ } } ], - "quotes": ["error", "single"], - "spellcheck/spell-checker": [ - "error", - { - "lang": "en_US", - "comments": false, - "strings": false, - "identifiers": true, - "templates": false, - "skipIfMatch": [ - "^\\$?..$" - ], - "skipWords": [ - "dx", // DevExpress - "el", // Element - "fn", // Function - "fx", // Effects - "jq", // jQuery - "js", // JavaScript - "ko", // Knockout - "ln", // Math - "na", // Special case for NaN - "ng", // Angular - "ok", // OK - "px", // Pixel - "tz", // Timezone - "ua", // User-agent - "ui", // User Interface - "un", // "Un-Escape" - "xs", // extra small - "xy", // XY-diagram - "vm", // view-model - - "amd", // AMD modules - "bing", - "browserslist", // auto-prefixer browsers list - "cldr", // Unicode CLDR Project - "cssom", // cssom parser - "cwd", // current working directory - "edm", // Entity Data Model - "eol", // end of line - "etag", // HTTP header - "eula", // EULA - "globals", // jest settings - "jsrender", // JsRender template engine - "hsl", // HSL color - "hsv", // HSV color - "iana", // IANA (time-zone database) - "ie", - "ie11", - "ios", - "ipad", - "iphone", - "linejoin", // SVG "stroke-linejoin" - "linux", - "ltr", // Left-to-Right - "mdx", // OLAP Multi-dimensional expressions - "mercator", // Map term - "microsoft", - "moz", // Vendor prefix - "mozilla", - "mvc", - "firefox", - "fmt", - "msie", - "odata", // OData - "readonly", - "rebase", // clean-css option - "rtl", // Right-to-Left - "scss", - "semver", // The semantic versioner for npm - "sinon", // JS library - "tspan", // SVG element - "tspans", - "uglify", // UglifyJS - "untils", // Time-zone term - "viapoint", // Geo term - "webkit", - "webpack", - "xmla", // XML for Analysis - "ldml", // LOCALE DATA MARKUP LANGUAGE - - "png", - "jpg", - "svg", - - "API", - "accessor", - "accessors", - "acos", - "activedescendant", - "adaptivity", - "addons", - "affine", - "aggregator", - "aggregators", - "ajax", - "ampm", - "anim", - "appt", - "appts", - "arabic", - "arg", - "argc", - "args", - "argv", - "asc", - "ascii", - "asin", - "aspnet", - "async", - "atan", - "attr", - "attributor", - "attrs", - "autocomplete", - "autocompletion", - "backend", - "backends", - "basename", - "bezier", - "bindable", - "bool", - "buf", - "calc", - "camelize", - "cancelable", - "captionize", - "ceil", - "centroid", - "checkbox", - "checkboxes", - "codomain", - "coef", - "coefs", - "coeff", - "coeffs", - "colgroup", - "colgroups", - "colorizer", - "colorizers", - "colspan", - "colspans", - "concat", - "cond", - "configs", - "configurator", - "configurators", - "const", - "consts", - "conv", - "coord", - "coords", - "cordova", - "cpus", - "crit", - "crosshair", - "ctor", - "ctors", - "ctrl", - "ctx", - "dasherize", - "dataset", - "datetime", - "dblclick", - "deactivator", - "dec", - "decrement", - "deferreds", - "defs", - "del", - "dels", - "denormalize", - "deps", - "desc", - "deserialization", - "deserialize", - "dest", - "dev", - "devtool", - "dir", - "dirname", - "dom", - "donut", - "downloader", - "draggable", - "draggables", - "drilldown", - "droppable", - "durations", - "eigen", - "elems", - "enctype", - "enqueue", - "enum", - "esc", - "etalon", - "exceedings", - "exchanger", - "expander", - "expando", - "expr", - "exprs", - "extname", - "extremum", - "fieldset", - "fieldsets", - "filename", - "focusable", - "focusin", - "focusout", - "foreach", - "formatter", - "formatters", - "fullscreen", - "func", - "funcs", - "gantt", - "gaussian", - "geo", - "geocode", - "geocoded", - "geocoder", - "getter", - "getters", - "gregorian", - "guid", - "gte", - "haspopup", - "hideable", - "historyless", - "hor", - "horz", - "hostname", - "hoverable", - "href", - "html", - "http", - "idx", - "img", - "impl", - "inflector", - "infobox", - "infos", - "init", - "inited", - "intervalize", - "invertible", - "invoker", - "iri", - "iso", - "iter", - "jsonp", - "keydown", - "len", - "lng", - "localizable", - "lookups", - "marginate", - "matcher", - "matchers", - "metadata", - "minify", - "mixin", - "mixins", - "multiline", - "multipane", - "multitouch", - "namespace", - "namespaced", - "namespaces", - "nav", - "navbar", - "noop", - "normalizer", - "num", - "observables", - "overline", - "paddings", - "param", - "params", - "parsers", - "patcher", - "pathname", - "pdf", - "penult", - "polyfill", - "polyline", - "polymorph", - "polynom", - "popout", - "popup", - "pos", - "postfix", - "postfixes", - "postprocess", - "pre", - "preload", - "prepend", - "prerender", - "prev", - "proj", - "proto", - "proxied", - "queryable", - "radian", - "radians", - "radiuses", - "readdir", - "rect", - "rects", - "registrator", - "reinit", - "rels", - "renderer", - "renderers", - "reposition", - "resample", - "resampled", - "resizable", - "resizables", - "resize", - "resized", - "resizer", - "resizing", - "resolvers", - "rgb", - "rgba", - "roadmap", - "rowspan", - "rowspans", - "sankey", - "scalebar", - "scrollable", - "scrollbar", - "scroller", - "scrollers", - "seg", - "selectable", - "semidiscrete", - "serializers", - "shader", - "sortable", - "sparkline", - "sparklines", - "splitter", - "sqrt", - "squarified", - "squarify", - "src", - "str", - "strikethrough", - "stringify", - "struct", - "stylesheets", - "sublevel", - "submenu", - "submenus", - "substr", - "substring", - "substrings", - "subtags", - "subvalue", - "subvalues", - "sugiyama", - "svg", - "swipeable", - "synchronizable", - "synchronizer", - "tabbable", - "tabindex", - "tbody", - "templated", - "thead", - "timeline", - "timestamp", - "timezones", - "titleize", - "tfoot", - "tmp", - "tmpl", - "toolbars", - "tooltip", - "tooltips", - "transclude", - "transcluded", - "treeview", - "turndown", - "uid", - "uint", - "unary", - "undelete", - "ungroup", - "ungrouping", - "unicode", - "unlink", - "unmap", - "unmerge", - "unmerged", - "unmocked", - "unproject", - "unregister", - "unselect", - "unselected", - "unshift", - "untranslate", - "updatable", - "uploader", - "uri", - "utc", - "utils", - "validator", - "validators", - "vals", - "ver", - "vert", - "viewport", - "vml", - "waypoint", - "waypoints", - "whitelist", - "winloss", - "workspace", - "writeable", - "xhr", - "xlsx", - "xml", - "xmlns" - ] - } - ] - } + "quotes": ["error", "single"] + }, + "overrides": [{ + "files": ["*.js"], + "parser": "babel-eslint" + }] } diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d3bf435e6fda..cbad98bd4887 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -6,7 +6,7 @@ jobs: test: strategy: matrix: - CONSTEL: [ export, misc, ui, ui.editors, ui.grid, ui.scheduler, viz ] + CONSTEL: [ export, misc, ui, ui.editors, ui.grid, ui.scheduler, viz, renovation ] runs-on: windows-latest timeout-minutes: 60 diff --git a/.gitignore b/.gitignore index e4dd4339f8be..4ebc2489bfe8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ /js/bundles/dx.custom.js /js/localization/default_messages.js /js/localization/cldr-data +/js/renovation/**/*.p.d.ts +/js/renovation/**/*.p.js +/js/renovation/**/*.j.d.ts +/js/renovation/**/*.j.js /themebuilder/data/less /themebuilder/data/metadata /scss diff --git a/.lintstagedrc b/.lintstagedrc index 26dfe75fb834..30ffecb9b207 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,4 +1,5 @@ { "*.js": ["eslint"], - "*.{css,less}": ["stylelint"] + "*.{css,less}": ["stylelint"], + "*.{ts,tsx}": ["npm run lint-ts"] } diff --git a/.travis.yml b/.travis.yml index 7f558a6a4111..1bbb0256931f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,6 +24,7 @@ env: - TARGET=test CONSTEL=ui.scheduler TZ='Japan' - TARGET=test CONSTEL=ui.scheduler TZ='Australia/ACT' - TARGET=test CONSTEL=viz + - TARGET=test CONSTEL=renovation - TARGET=test PERF=true JQUERY=true NO_HEADLESS=true - TARGET=test MOBILE_UA=ios9 CONSTEL=ui - TARGET=test MOBILE_UA=ios9 CONSTEL=ui.editors NO_HEADLESS=true @@ -42,11 +43,13 @@ env: - TARGET=test BROWSER=firefox JQUERY=true CONSTEL=ui.grid - TARGET=test BROWSER=firefox JQUERY=true CONSTEL=ui.scheduler - TARGET=test BROWSER=firefox JQUERY=true CONSTEL=viz + - TARGET=test BROWSER=firefox JQUERY=true CONSTEL=renovation - TARGET=test_functional COMPONENT=dataGrid QUARANTINE_MODE=true - TARGET=test_functional COMPONENT=scheduler QUARANTINE_MODE=true - TARGET=test_functional COMPONENT=editors - TARGET=test_functional COMPONENT=navigation - TARGET=test_themebuilder + - TARGET=test_jest - TARGET=test_scss cache: diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000000..be8c07099b83 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible Node.js debug attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Jest Tests", + "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", + "args": [ + "--runInBand" + ], + "runtimeArgs": [ + "--harmony" + ], + "sourceMaps": true, + "cwd": "${workspaceRoot}" + }, + ] +} diff --git a/build/gulp/generator/gulpfile.js b/build/gulp/generator/gulpfile.js new file mode 100644 index 000000000000..7358e9f9a03c --- /dev/null +++ b/build/gulp/generator/gulpfile.js @@ -0,0 +1,153 @@ +'use strict'; + +const gulp = require('gulp'); +const { generateComponents } = require('devextreme-generator/component-compiler'); +const generator = require('devextreme-generator/preact-generator').default; +const ts = require('gulp-typescript'); +const lint = require('gulp-eslint'); +const plumber = require('gulp-plumber'); +const gulpIf = require('gulp-if'); +const babel = require('gulp-babel'); +const notify = require('gulp-notify'); +const watch = require('gulp-watch'); + +const SRC = ['js/renovation/**/*.tsx']; +const DEST = 'js/renovation/'; + +const COMMON_SRC = ['js/**/*.*', `!${SRC}`]; + +const knownErrors = [ + 'Cannot find module \'preact\'.', + 'Cannot find module \'preact/hooks\'.', + 'Cannot find module \'preact/compat\'.' +]; + +gulp.task('generate-components', function() { + const tsProject = ts.createProject('build/gulp/generator/ts-configs/preact.tsconfig.json'); + generator.defaultOptionsModule = 'js/core/options/utils'; + generator.jqueryComponentRegistratorModule = 'js/core/component_registrator'; + generator.jqueryBaseComponentModule = 'js/renovation/preact-wrapper/component'; + + return gulp.src(SRC) + .pipe(generateComponents(generator)) + .pipe(plumber(()=>null)) + .pipe(tsProject({ + error(e) { + if(!knownErrors.some(i => e.message.endsWith(i))) { + console.log(e.message); + } + }, + finish() {} + })) + .pipe(gulpIf(file => file.extname === '.js', + lint({ + quiet: true, + fix: true, + useEslintrc: true + }) + )) + .pipe(lint.format()) + .pipe(gulp.dest(DEST)); +}); + +function addGenerationTask( + frameworkName, + knownErrors = [], + compileTs = true, + copyArtifacts = false, + babelGeneratedFiles = true +) { + const frameworkDest = `artifacts/${frameworkName}`; + const generator = require(`devextreme-generator/${frameworkName}-generator`).default; + let tsProject = () => () => { }; + if(compileTs) { + tsProject = ts.createProject(`build/gulp/generator/ts-configs/${frameworkName}.tsconfig.json`); + } + + generator.defaultOptionsModule = 'js/core/options/utils'; + + gulp.task(`generate-${frameworkName}-declaration-only`, function() { + return gulp.src('js/**/*.tsx') + .pipe(generateComponents(generator)) + .pipe(plumber(() => null)) + .pipe(gulpIf(compileTs, tsProject({ + error(e) { + if(!knownErrors.some(i => e.message.endsWith(i))) { + console.log(e.message); + } + }, + finish() { } + }))) + .pipe(gulpIf(babelGeneratedFiles, babel())) + .pipe(gulp.dest(frameworkDest)); + }); + + const artifactsSrc = ['./artifacts/css/**/*', `./artifacts/${frameworkName}/**/*`]; + + const generateSeries = [ + `generate-${frameworkName}-declaration-only`, + function() { + return gulp.src(COMMON_SRC) + .pipe( + gulpIf( + file => file.extname === '.js', + babel() + ) + ) + .pipe(gulp.dest(frameworkDest)); + }]; + + if(copyArtifacts) { + generateSeries.push(function copyArtifacts() { + return gulp.src(artifactsSrc, { base: './artifacts/' }) + .pipe(gulp.dest(`./playground/${frameworkName}/src/artifacts`)); + }); + } + + gulp.task(`generate-${frameworkName}`, gulp.series(...generateSeries)); + + const watchTasks = [ + function() { + watch(COMMON_SRC) + .pipe(plumber({ + errorHandler: notify.onError('Error: <%= error.message %>') + .bind() // bind call is necessary to prevent firing 'end' event in notify.onError implementation + })) + .pipe( + gulpIf( + file => file.extname === '.js', + babel() + ) + ) + .pipe(gulp.dest(frameworkDest)); + }, + function declarationBuild() { + gulp.watch(SRC, gulp.series(`generate-${frameworkName}-declaration-only`)); + } + ]; + + if(copyArtifacts) { + watchTasks.push(function copyArtifacts() { + return gulp.src(artifactsSrc, { base: './artifacts/' }) + .pipe(watch(artifactsSrc, { base: './artifacts/', readDelay: 1000 })) + .pipe(gulp.dest(`./playground/${frameworkName}/src/artifacts`)); + }); + } + + gulp.task(`generate-${frameworkName}-watch`, gulp.series( + `generate-${frameworkName}`, + gulp.parallel(...watchTasks) + )); +} + +addGenerationTask('react', ['Cannot find module \'csstype\'.'], false, true, false); +addGenerationTask('angular', [ + 'Cannot find module \'@angular/core\'.', + 'Cannot find module \'@angular/common\'.' +]); + +addGenerationTask('vue', [], false, true, false); + +gulp.task('generate-components-watch', gulp.series('generate-components', function() { + gulp.watch(SRC, gulp.series('generate-components')); +})); diff --git a/build/gulp/generator/ts-configs/angular.tsconfig.json b/build/gulp/generator/ts-configs/angular.tsconfig.json new file mode 100644 index 000000000000..2dedc812db31 --- /dev/null +++ b/build/gulp/generator/ts-configs/angular.tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + } +} diff --git a/build/gulp/generator/ts-configs/preact.tsconfig.json b/build/gulp/generator/ts-configs/preact.tsconfig.json new file mode 100644 index 000000000000..b21bba7e312f --- /dev/null +++ b/build/gulp/generator/ts-configs/preact.tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "jsxFactory": "Preact.h" + } +} diff --git a/build/gulp/generator/ts-configs/react.tsconfig.json b/build/gulp/generator/ts-configs/react.tsconfig.json new file mode 100644 index 000000000000..08be024c5d52 --- /dev/null +++ b/build/gulp/generator/ts-configs/react.tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowSyntheticDefaultImports": true + } +} diff --git a/build/gulp/generator/ts-configs/tsconfig.json b/build/gulp/generator/ts-configs/tsconfig.json new file mode 100644 index 000000000000..f7e064c71382 --- /dev/null +++ b/build/gulp/generator/ts-configs/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "strict": false, + "esModuleInterop": false, + "typeRoots": ["node_modules/@types"], + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "declaration": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/build/gulp/transpile.js b/build/gulp/transpile.js index e586f613fe70..448403e11a28 100644 --- a/build/gulp/transpile.js +++ b/build/gulp/transpile.js @@ -10,15 +10,16 @@ const notify = require('gulp-notify'); const context = require('./context.js'); +require('./generator/gulpfile'); + const GLOB_TS = require('./ts').GLOB_TS; -const SRC = ['js/**/*.*', '!' + GLOB_TS]; +const SRC = ['js/**/*.*', '!' + GLOB_TS, '!js/**/*.tsx']; const TESTS_PATH = 'testing'; const TESTS_SRC = TESTS_PATH + '/**/*.js'; const VERSION_FILE_PATH = 'core/version.js'; - -gulp.task('transpile', gulp.series('bundler-config', function() { +gulp.task('transpile', gulp.series('generate-components', 'bundler-config', function() { return gulp.src(SRC) .pipe(babel()) .pipe(gulp.dest(context.TRANSPILED_PATH)); diff --git a/build/gulp/ts.js b/build/gulp/ts.js index 23c4737660b3..fbaf6f5b8161 100644 --- a/build/gulp/ts.js +++ b/build/gulp/ts.js @@ -52,6 +52,7 @@ gulp.task('ts-bundle', gulp.series( gulp.task('ts-jquery-check', gulp.series('ts-bundle', function checkJQueryAugmentations() { let content = `/// \n`; + content += 'import * as $ from \'jquery\';'; content += MODULES .map(function(moduleMeta) { diff --git a/build/gulp/tsconfig.json b/build/gulp/tsconfig.json new file mode 100644 index 000000000000..9f756e497164 --- /dev/null +++ b/build/gulp/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "typeRoots": ["node_modules/@types"], + "noEmitOnError": true + } +} diff --git a/build/gulp/vendor.js b/build/gulp/vendor.js index ac315bae31d8..6b3ffca7df76 100644 --- a/build/gulp/vendor.js +++ b/build/gulp/vendor.js @@ -13,6 +13,17 @@ const JS_VENDORS = [ { path: '/angular/angular.js' }, + { + path: '/preact/dist/preact.js' + }, + { + path: '/preact/hooks/dist/hooks.js', + noUglyFile: true + }, + { + path: '/preact/compat/dist/compat.js', + noUglyFile: true + }, { path: '/jquery/dist/jquery.js' }, diff --git a/docker-ci.sh b/docker-ci.sh index b4201b361298..f7d08aebc7dc 100755 --- a/docker-ci.sh +++ b/docker-ci.sh @@ -189,6 +189,14 @@ function run_test_functional { npm run test-functional -- $args } +function run_test_jest { + export DEVEXTREME_TEST_CI=true + + npm i + npx gulp generate-components + npm run test-jest +} + function run_test_scss { npm i npx gulp generate-scss diff --git a/gulpfile.js b/gulpfile.js index 5ed1e5ff6cc5..0bb5d29bea1a 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -21,6 +21,7 @@ require('./build/gulp/vendor'); require('./build/gulp/ts'); require('./build/gulp/localization'); require('./build/gulp/style-compiler'); +require('./build/gulp/generator/gulpfile'); require('./build/gulp/scss/tasks'); const TEST_CI = Boolean(process.env['DEVEXTREME_TEST_CI']); @@ -69,4 +70,4 @@ gulp.task('style-compiler-batch', createStyleCompilerBatch()); gulp.task('default', createDefaultBatch()); -gulp.task('dev', gulp.parallel('bundler-config-dev', 'js-bundles-dev', 'style-compiler-themes-dev')); +gulp.task('dev', gulp.parallel('bundler-config-dev', 'generate-components-watch', 'js-bundles-dev', 'style-compiler-themes-dev')); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000000..6fecc04644c9 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,39 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html +const path = require('path'); +const resolve = require('resolve'); + +module.exports = { + 'globals': { + 'ts-jest': { + tsConfig: './testing/jest/tsconfig.json', + diagnostics: false, // set to true to enable type checking + } + }, + collectCoverage: true, + collectCoverageFrom: [ + './js/renovation/**/*.p.js', + '!./js/renovation/number-box.p.js', + '!./js/renovation/select-box.p.js', + ], + coverageDirectory: './testing/jest/code_coverage', + coverageThreshold: { + './js/renovation/**/*.p.js': { + functions: 100, + statements: 100, + lines: 100, + branches: 100 + } + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + preset: 'ts-jest', + setupFiles: [ + path.join(path.resolve('.'), './testing/jest/setup-enzyme.ts'), + ], + testMatch: [ + path.join(path.resolve('.'), './testing/jest/**/*.tests.[jt]s?(x)') + ], + transform: { + '.(js|jsx|ts|tsx)': resolve.sync('ts-jest') + } +}; diff --git a/js/.eslintrc b/js/.eslintrc index 251a6c8d0139..82bd686698cc 100644 --- a/js/.eslintrc +++ b/js/.eslintrc @@ -1,4 +1,8 @@ { + "env": { + "es6": true, + "node": false + }, "parserOptions": { "sourceType": "module" }, diff --git a/js/bundles/modules/parts/widgets-base.js b/js/bundles/modules/parts/widgets-base.js index 956caccba6d7..daddceea96ce 100644 --- a/js/bundles/modules/parts/widgets-base.js +++ b/js/bundles/modules/parts/widgets-base.js @@ -89,4 +89,9 @@ ui.CollectionWidget = require('../../../ui/collection/ui.collection_widget.edit' ui.dxDropDownEditor = require('../../../ui/drop_down_editor/ui.drop_down_editor'); // Reports +// Renovation +ui.Button = require('../../../renovation/button.j').default; +ui.Widget = require('../../../renovation/widget.j').default; +// Renovation + module.exports = ui; diff --git a/js/core/dom_component.js b/js/core/dom_component.js index f3344bd6a2fc..01f55cbffbfe 100644 --- a/js/core/dom_component.js +++ b/js/core/dom_component.js @@ -437,6 +437,7 @@ const DOMComponent = Component.inherit({ if(anonymousTemplateMeta.name && !anonymousTemplate) { this._options.silent(`integrationOptions.templates.${anonymousTemplateMeta.name}`, anonymousTemplateMeta.template); + this._options.silent('_hasAnonymousTemplateContent', true); } }, diff --git a/js/core/options/utils.js b/js/core/options/utils.js index 84df374783ca..12c0cb8af957 100644 --- a/js/core/options/utils.js +++ b/js/core/options/utils.js @@ -35,3 +35,7 @@ export const getNestedOptionValue = function(optionsObject, name) { cachedGetters[name] = cachedGetters[name] || compileGetter(name); return cachedGetters[name](optionsObject, { functionsAsIs: true }); }; + +export default function createDefaultOptionRules(options = []) { + return options; +} diff --git a/js/core/utils/icon.js b/js/core/utils/icon.js index ef0175f29986..20c39f4767cd 100644 --- a/js/core/utils/icon.js +++ b/js/core/utils/icon.js @@ -3,7 +3,7 @@ import $ from '../../core/renderer'; const ICON_CLASS = 'dx-icon'; const SVG_ICON_CLASS = 'dx-svg-icon'; -const getImageSourceType = (source) => { +export const getImageSourceType = (source) => { if(!source || typeof source !== 'string') { return false; } @@ -27,7 +27,7 @@ const getImageSourceType = (source) => { return false; }; -const getImageContainer = (source) => { +export const getImageContainer = (source) => { switch(getImageSourceType(source)) { case 'image': return $('').attr('src', source).addClass(ICON_CLASS); @@ -41,6 +41,3 @@ const getImageContainer = (source) => { return null; } }; - -exports.getImageSourceType = getImageSourceType; -exports.getImageContainer = getImageContainer; diff --git a/js/events/pointer.js b/js/events/pointer.js index 54e58b03967b..6d4fc9a8d207 100644 --- a/js/events/pointer.js +++ b/js/events/pointer.js @@ -1,4 +1,4 @@ -import support from '../core/utils/support'; +import * as support from '../core/utils/support'; import { each } from '../core/utils/iterator'; import browser from '../core/utils/browser'; import devices from '../core/devices'; diff --git a/js/events/short.js b/js/events/short.js index 22c8d72a4e39..b4662be5e0a6 100644 --- a/js/events/short.js +++ b/js/events/short.js @@ -7,15 +7,19 @@ function addNamespace(event, namespace) { return namespace ? pureAddNamespace(event, namespace) : event; } +function executeAction(action, args) { + return typeof action === 'function' ? action(args) : action.execute(args); +} + export const active = { on: ($el, active, inactive, opts) => { const { selector, showTimeout, hideTimeout, namespace } = opts; eventsEngine.on($el, addNamespace('dxactive', namespace), selector, { timeout: showTimeout }, - event => active.execute({ event, element: event.currentTarget }) + event => executeAction(active, { event, element: event.currentTarget }) ); eventsEngine.on($el, addNamespace('dxinactive', namespace), selector, { timeout: hideTimeout }, - event => inactive.execute({ event, element: event.currentTarget }) + event => executeAction(inactive, { event, element: event.currentTarget }) ); }, @@ -37,9 +41,8 @@ export const resize = { export const hover = { on: ($el, start, end, { selector, namespace }) => { eventsEngine.on($el, addNamespace('dxhoverend', namespace), selector, event => end(event)); - eventsEngine.on($el, addNamespace('dxhoverstart', namespace), selector, event => { - start.execute({ element: event.target, event }); - }); + eventsEngine.on($el, addNamespace('dxhoverstart', namespace), selector, + event => executeAction(start, { element: event.target, event })); }, off: ($el, { selector, namespace }) => { @@ -67,7 +70,7 @@ export const focus = { if(domAdapter.hasDocumentProperty('onbeforeactivate')) { eventsEngine.on($el, addNamespace('beforeactivate', namespace), - e => isFocusable(e.target) || e.preventDefault() + e => isFocusable(null, e.target) || e.preventDefault() ); } }, diff --git a/js/renovation/.eslintrc b/js/renovation/.eslintrc new file mode 100644 index 000000000000..b25516aa9f13 --- /dev/null +++ b/js/renovation/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "devextreme/renovation-declarations" + ] +} diff --git a/js/renovation/button.tsx b/js/renovation/button.tsx new file mode 100644 index 000000000000..6f7ddd5903b5 --- /dev/null +++ b/js/renovation/button.tsx @@ -0,0 +1,266 @@ +import { + Component, + ComponentBindings, + Effect, + Event, + JSXComponent, + Method, + OneWay, + Ref, + Template, +} from 'devextreme-generator/component_declaration/common'; +import createDefaultOptionRules from '../core/options/utils'; +import devices from '../core/devices'; +import noop from './utils/noop'; +import themes from '../ui/themes'; +import { click } from '../events/short'; +import { getImageSourceType } from '../core/utils/icon'; +import Icon from './icon'; +import InkRipple from './ink-ripple'; +import Widget from './widget'; +import { BaseWidgetProps } from './utils/base-props'; +import BaseComponent from './preact-wrapper/button'; + +const stylingModes = ['outlined', 'text', 'contained']; + +const getInkRippleConfig = ({ text, icon, type }: ButtonProps) => { + const isOnlyIconButton = (!text && icon) || (type === 'back'); + const config: any = isOnlyIconButton ? { + isCentered: true, + useHoldAnimation: false, + waveSizeCoefficient: 1, + } : {}; + + return config; +}; + +const getCssClasses = (model: ButtonProps) => { + const { + text, icon, stylingMode, type, iconPosition, + } = model; + const classNames = ['dx-button']; + const isValidStylingMode = stylingMode && stylingModes.indexOf(stylingMode) !== -1; + + classNames.push(`dx-button-mode-${isValidStylingMode ? stylingMode : 'contained'}`); + classNames.push(`dx-button-${type || 'normal'}`); + + text && classNames.push('dx-button-has-text'); + icon && classNames.push('dx-button-has-icon'); + iconPosition !== 'left' && classNames.push('dx-button-icon-right'); + + return classNames.join(' '); +}; + +const getAriaLabel = (text, icon) => { + let label = (text && text.trim()) || icon; + + if (!text && getImageSourceType(icon) === 'image') { + label = icon.indexOf('base64') === -1 ? icon.replace(/.+\/([^.]+)\..+$/, '$1') : 'Base64'; + } + + return label ? { label } : {}; +}; + +export const viewFunction = (viewModel: Button) => { + const { + icon, iconPosition, template, text, + } = viewModel.props; + const renderText = !template && text; + const isIconLeft = iconPosition === 'left'; + const iconComponent = !template && viewModel.iconSource + && ; + + return ( + +
+ {template + && ( + + )} + {isIconLeft && iconComponent} + {renderText + && {text}} + {!isIconLeft && iconComponent} + {viewModel.props.useSubmitBehavior + && } + {viewModel.props.useInkRipple + && } +
+
+ ); +}; + +@ComponentBindings() +export class ButtonProps extends BaseWidgetProps { + @OneWay() activeStateEnabled?: boolean = true; + + @OneWay() hoverStateEnabled?: boolean = true; + + @OneWay() icon?: string = ''; + + @OneWay() iconPosition?: string = 'left'; + + @Event({ + actionConfig: { excludeValidators: ['readOnly'] }, + }) + onClick?: (e: any) => any = noop; + + @Event() onSubmit?: (e: any) => any = noop; + + @OneWay() pressed?: boolean; + + @OneWay() stylingMode?: 'outlined' | 'text' | 'contained'; + + @Template({ canBeAnonymous: true }) template?: any = ''; + + @OneWay() text?: string = ''; + + @OneWay() type?: string; + + @OneWay() useInkRipple?: boolean = false; + + @OneWay() useSubmitBehavior?: boolean = false; + + @OneWay() validationGroup?: string = undefined; +} + +const defaultOptionRules = createDefaultOptionRules([{ + device: () => devices.real().deviceType === 'desktop' && !(devices as any).isSimulator(), + options: { focusStateEnabled: true }, +}, { + device: () => (themes as any).isMaterial(themes.current()), + options: { useInkRipple: true }, +}]); +@Component({ + defaultOptionRules, + jQuery: { + component: BaseComponent, + register: true, + }, + view: viewFunction, +}) + +export default class Button extends JSXComponent { + @Ref() contentRef!: HTMLDivElement; + + @Ref() inkRippleRef!: InkRipple; + + @Ref() submitInputRef!: HTMLInputElement; + + @Ref() widgetRef!: Widget; + + @Effect() + contentReadyEffect() { + // NOTE: we should trigger this effect on change each + // property upon which onContentReady depends + // (for example, text, icon, etc) + const { onContentReady } = this.props; + + onContentReady!({ element: this.contentRef.parentNode }); + } + + @Method() + focus() { + this.widgetRef.focus(); + } + + onActive(event: Event) { + const { useInkRipple } = this.props; + + useInkRipple && this.inkRippleRef.showWave({ element: this.contentRef, event }); + } + + onInactive(event: Event) { + const { useInkRipple } = this.props; + + useInkRipple && this.inkRippleRef.hideWave({ element: this.contentRef, event }); + } + + onWidgetClick(event: Event) { + const { onClick, useSubmitBehavior, validationGroup } = this.props; + + onClick!({ event, validationGroup }); + useSubmitBehavior && this.submitInputRef.click(); + } + + onWidgetKeyDown(event: Event, options) { + const { onKeyDown } = this.props; + const { keyName, which } = options; + + const result = onKeyDown?.(event, options); + if (result?.cancel) { + return result; + } + + if (keyName === 'space' || which === 'space' || keyName === 'enter' || which === 'enter') { + event.preventDefault(); + this.onWidgetClick(event); + } + + return undefined; + } + + @Effect() + submitEffect() { + const namespace = 'UIFeedback'; + const { useSubmitBehavior, onSubmit } = this.props; + + if (useSubmitBehavior) { + click.on(this.submitInputRef, + (event) => onSubmit!({ event, submitInput: this.submitInputRef }), + { namespace }); + + return () => click.off(this.submitInputRef, { namespace }); + } + + return undefined; + } + + get aria() { + return getAriaLabel(this.props.text, this.props.icon); + } + + get cssClasses(): string { + return getCssClasses(this.props); + } + + get elementAttr() { + return { ...this.props.elementAttr, role: 'button' }; + } + + get iconSource(): string { + const { icon, type } = this.props; + + return (icon || type === 'back') ? (icon || 'back') : ''; + } + + get inkRippleConfig() { + return getInkRippleConfig(this.props); + } +} diff --git a/js/renovation/error-message.tsx b/js/renovation/error-message.tsx new file mode 100644 index 000000000000..2cdab02380ad --- /dev/null +++ b/js/renovation/error-message.tsx @@ -0,0 +1,25 @@ +import { + Component, ComponentBindings, JSXComponent, OneWay, +} from 'devextreme-generator/component_declaration/common'; + +export const viewFunction = ({ props: { message, className }, restAttributes }: ErrorMessage) => ( +
+ {message} +
+); + +@ComponentBindings() +export class ErrorMessageProps { + @OneWay() className?: string = ''; + + @OneWay() message?: string = ''; +} + +@Component({ + defaultOptionRules: null, + view: viewFunction, +}) +export default class ErrorMessage extends JSXComponent {} diff --git a/js/renovation/icon.tsx b/js/renovation/icon.tsx new file mode 100644 index 000000000000..454d8fdeef93 --- /dev/null +++ b/js/renovation/icon.tsx @@ -0,0 +1,34 @@ +import { + Component, ComponentBindings, JSXComponent, OneWay, Fragment, +} from 'devextreme-generator/component_declaration/common'; +import { getImageSourceType } from '../core/utils/icon'; + +export const viewFunction = ({ sourceType, cssClass, props: { source } }: Icon) => ( + + {sourceType === 'dxIcon' && } + {sourceType === 'fontIcon' && } + {sourceType === 'image' && } + {sourceType === 'svg' && {source}} + +); + +@ComponentBindings() +export class IconProps { + @OneWay() position?: string = 'left'; + + @OneWay() source?: string = ''; +} + +@Component({ + defaultOptionRules: null, + view: viewFunction, +}) +export default class Icon extends JSXComponent { + get sourceType() { + return getImageSourceType(this.props.source); + } + + get cssClass() { + return this.props.position !== 'left' ? 'dx-icon-right' : ''; + } +} diff --git a/js/renovation/ink-ripple.tsx b/js/renovation/ink-ripple.tsx new file mode 100644 index 000000000000..1d2e980f5365 --- /dev/null +++ b/js/renovation/ink-ripple.tsx @@ -0,0 +1,38 @@ +import { + Component, ComponentBindings, JSXComponent, OneWay, Method, +} from 'devextreme-generator/component_declaration/common'; +import { initConfig, showWave, hideWave } from '../ui/widget/utils.ink_ripple'; + +// TODO: remake old ink ripple in new JSX component +export const viewFunction = (model: InkRipple) => ( +
+); + +@ComponentBindings() +export class InkRippleProps { + @OneWay() config?: any = {}; +} + +@Component({ + defaultOptionRules: null, + view: viewFunction, +}) +export default class InkRipple extends JSXComponent { + @Method() + hideWave(event) { + hideWave(this.getConfig, event); + } + + @Method() + showWave(event) { + showWave(this.getConfig, event); + } + + get getConfig() { + const { config } = this.props; + return initConfig(config); + } +} diff --git a/js/renovation/number-box.tsx b/js/renovation/number-box.tsx new file mode 100644 index 000000000000..c6fcb2129ee0 --- /dev/null +++ b/js/renovation/number-box.tsx @@ -0,0 +1,56 @@ +import { + Ref, Effect, Component, ComponentBindings, JSXComponent, OneWay, Event, TwoWay, +} from 'devextreme-generator/component_declaration/common'; +import DxNumberBox from '../ui/number_box'; +import { WidgetProps } from './widget'; + +export const viewFunction = ({ widgetRef }: NumberBox) => (
); + +@ComponentBindings() +export class NumberBoxProps extends WidgetProps { + // props was copied from js\ui\number_box.d.ts + + // buttons?: Array<'clear' | 'spins' | dxTextEditorButton>; + // format?: format; + @OneWay() invalidValueMessage?: string; + + @OneWay() max?: number; + + @OneWay() min?: number; + + @OneWay() mode?: 'number' | 'text' | 'tel'; + + // Needed only for jQuery. Should be auto-generated + // onValueChanged?: ((e: { component?: T, element?: dxElement, model?: any, + // value?: any, previousValue?: any, event?: event }) => any); + @OneWay() showSpinButtons?: boolean; + + @OneWay() step?: number; + + @OneWay() useLargeSpinButtons?: boolean; + + @TwoWay() value?: number; + + @Event() valueChange?: ((value: number) => void) = () => {}; +} + +@Component({ + defaultOptionRules: null, + view: viewFunction, +}) +export default class NumberBox extends JSXComponent { + @Ref() + widgetRef!: HTMLDivElement; + + @Effect() + setupWidget() { + const { valueChange } = this.props; + + new DxNumberBox(this.widgetRef, { // eslint-disable-line no-new + ...this.props as any, + onValueChanged: (e) => { + valueChange!(e.value); + }, + }); + } +} diff --git a/js/renovation/preact-wrapper/button.d.ts b/js/renovation/preact-wrapper/button.d.ts new file mode 100644 index 000000000000..7b0968779962 --- /dev/null +++ b/js/renovation/preact-wrapper/button.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable no-underscore-dangle */ +import Component from './component'; + +export default class Button extends Component { + getAllProps(isFirstRender: boolean): any; + + _init(): any; + + _getSubmitAction(): any; + + _findGroup(): any; +} +/* eslint-enable no-underscore-dangle */ diff --git a/js/renovation/preact-wrapper/button.js b/js/renovation/preact-wrapper/button.js new file mode 100644 index 000000000000..fab660e39eb0 --- /dev/null +++ b/js/renovation/preact-wrapper/button.js @@ -0,0 +1,59 @@ +/* eslint-disable */ +import ValidationEngine from '../../ui/validation_engine'; +import Component from './component'; + +export default class Button extends Component { + _init() { + super._init(); + this._addAction('onSubmit', this._getSubmitAction()); + } + + getAllProps(isFirstRender) { + const props = super.getAllProps(isFirstRender); + props.validationGroup = this._validationGroupConfig; + return props; + } + + _getSubmitAction() { + let needValidate = true; + let validationStatus = 'valid'; + + return this._createAction(({ event, submitInput }) => { + if(needValidate) { + const validationGroup = this._validationGroupConfig; + + if(validationGroup) { + const { status, complete } = validationGroup.validate(); + + validationStatus = status; + + if(status === 'pending') { + needValidate = false; + this.option('disabled', true); + + complete.then(({ status }) => { + needValidate = true; + this.option('disabled', false); + + validationStatus = status; + validationStatus === 'valid' && submitInput.click(); + }); + } + } + } + + validationStatus !== 'valid' && event.preventDefault(); + event.stopPropagation(); + }); + } + + get _validationGroupConfig() { + return ValidationEngine.getGroupConfig(this._findGroup()); + } + + _findGroup() { + const $element = this.$element(); + return this.option('validationGroup') || ValidationEngine.findGroup($element, this._modelByElement($element)); + } +} +/* eslint-enable */ diff --git a/js/renovation/preact-wrapper/component.d.ts b/js/renovation/preact-wrapper/component.d.ts new file mode 100644 index 000000000000..e09972a4876a --- /dev/null +++ b/js/renovation/preact-wrapper/component.d.ts @@ -0,0 +1,38 @@ +/* eslint-disable no-underscore-dangle */ +import DOMComponent from '../../core/dom_component'; + +export default class PreactWrapper extends DOMComponent { + viewRef: any; + + getInstance(): this; + + _initMarkup(): void; + + _render(): void; + + getAllProps(isFirstRender: boolean): any; + + _getActionsMap(): any; + + _init(): any; + + _createViewRef(): any; + + _optionChanged(option: any): any; + + _addAction(name: string, config: any): any; + + _stateChange(name: string): (value: any) => void; + + _createTemplateComponent(props: any, templateOption: any, canBeAnonymous: boolean): any; + + _wrapKeyDownHandler(handler: (event: any, options: any) => any): any; + + // Public API + repaint(): any; + + registerKeyHandler(key: string, handler: (e: any) => any): void; + + setAria(): any; +} +/* eslint-enable no-underscore-dangle */ diff --git a/js/renovation/preact-wrapper/component.js b/js/renovation/preact-wrapper/component.js new file mode 100644 index 000000000000..946305223145 --- /dev/null +++ b/js/renovation/preact-wrapper/component.js @@ -0,0 +1,190 @@ +/* eslint-disable */ +import $ from '../../core/renderer'; +import DOMComponent from '../../core/dom_component'; +import * as Preact from 'preact'; +import { isEmpty } from '../../core/utils/string'; +import { wrapElement, removeDifferentElements } from '../preact-wrapper/utils'; +import { useLayoutEffect } from 'preact/hooks'; +import { getPublicElement } from '../../core/element'; + +const TEMPLATE_WRAPPER_CLASS = 'dx-template-wrapper'; + +export default class PreactWrapper extends DOMComponent { + getInstance() { + return this; + } + + _initMarkup() { + const isFirstRender = this.$element().children().length === 0; + const hasParent = this.$element().parent().length > 0; + const container = isFirstRender && hasParent ? this.$element().get(0) : undefined; + + Preact.render(Preact.h(this._viewComponent, this.getAllProps(isFirstRender)), this.$element().get(0), container); + } + + _render() { + // NOTE: see ui.widget + // this._renderContent(); + } + // _renderContent() { } + + getAllProps(isFirstRender) { + const options = { ...this.option() }; + const attributes = this.$element()[0].attributes; + const { width, height } = this.$element()[0].style; + + if(isFirstRender) { + options.elementAttr = { + ...Object.keys(attributes).reduce((a, key) => { + if(attributes[key].specified) { + a[attributes[key].name] = attributes[key].value; + } + return a; + }, {}), + ...options.elementAttr + }; + } else { + if(attributes.id) { + // NOTE: workaround to save container id + options.elementAttr = { + [attributes.id.name]: attributes.id.value, + ...options.elementAttr + }; + } + if(attributes.class) { + // NOTE: workaround to save custom classes on type changes + const customClass = attributes.class.value + .split(' ') + .filter(name => name.indexOf('dx-') < 0) + .join(' '); + const classes = options.elementAttr.class ? options.elementAttr.class.concat(customClass) : customClass; + options.elementAttr = { class: classes, ...options.elementAttr }; + } + } + if(!isEmpty(width)) { + options.width = width; + } + if(!isEmpty(height)) { + options.height = height; + } + + if(this.viewRef) { + options.ref = this.viewRef; + } + + Object.keys(this._actionsMap).forEach(name => { + options[name] = this._actionsMap[name]; + }); + + return this.getProps && this.getProps(options) || options; + } + + _getActionConfigs() { return {}; } + + _init() { + super._init(); + this._actionsMap = {}; + + Object.keys(this._getActionConfigs()).forEach(name => this._addAction(name)); + + this._initWidget && this._initWidget(); + this._supportedKeys = () => ({}); + } + + _addAction(event, action) { + this._actionsMap[event] = action || this._createActionByOption(event, this._getActionConfigs()[event]); + } + + _createViewRef() { + this.viewRef = Preact.createRef(); + } + + _optionChanged(option) { + const { name } = option || {}; + if(name && this._getActionConfigs()[name]) { + this._addAction(name); + } + + super._optionChanged(option); + this._invalidate(); + } + + _stateChange(name) { + return (value) => this.option(name, value); + } + + _createTemplateComponent(props, templateOption, canBeAnonymous) { + if(!templateOption && this.option('_hasAnonymousTemplateContent') && canBeAnonymous) { + templateOption = this._templateManager.anonymousTemplateName; + } + if(!templateOption) { + return; + } + + const template = this._getTemplate(templateOption); + return ({ parentRef, ...restProps }) => { + useLayoutEffect(() => { + const $parent = $(parentRef.current); + const $children = $parent.contents(); + + let $template = $(template.render({ + container: getPublicElement($parent), + model: restProps, + transclude: canBeAnonymous && templateOption === this._templateManager.anonymousTemplateName, + // TODO index + })); + + if($template.hasClass(TEMPLATE_WRAPPER_CLASS)) { + $template = wrapElement($parent, $template); + } + const $newChildren = $parent.contents(); + + return () => { + // NOTE: order is important + removeDifferentElements($children, $newChildren); + }; + }, Object.keys(props).map(key => props[key])); + + return (); + }; + } + + _wrapKeyDownHandler(handler) { + return (event, options) => { + const { originalEvent, keyName, which } = options; + const keys = this._supportedKeys(); + const func = keys[keyName] || keys[which]; + + // NOTE: registered handler has more priority + if(func !== undefined) { + const handler = func.bind(this); + const result = handler(originalEvent, options); + + if(!result) { + event.cancel = true; + return event; + } + } + + // NOTE: make it possible to pass onKeyDown property + return handler?.(event, options); + }; + } + + // Public API + repaint() { + this._refresh(); + } + + registerKeyHandler(key, handler) { + const currentKeys = this._supportedKeys(); + this._supportedKeys = () => ({ ...currentKeys, [key]: handler }); + } + + // NOTE: this method will be deprecated + // aria changes should be defined in declaration or passed through property + setAria() { + throw new Error('"setAria" method is deprecated, use "aria" property instead'); + } +} +/* eslint-enable */ diff --git a/js/renovation/preact-wrapper/utils.js b/js/renovation/preact-wrapper/utils.js new file mode 100644 index 000000000000..3ff12ea8c6ee --- /dev/null +++ b/js/renovation/preact-wrapper/utils.js @@ -0,0 +1,40 @@ +import { each } from '../../core/utils/iterator'; + +const addAttributes = ($element, attributes) => { + each(attributes, (_, { name, value }) => { + if (name === 'class') { + $element.addClass(value); + } else { + $element.attr(name, value); + } + }); +}; + +// NOTE: function for jQuery templates +export const wrapElement = ($element, $wrapper) => { + const { attributes } = $wrapper.get(0); + const children = $wrapper.contents(); + + addAttributes($element, attributes); + + $wrapper.remove(); + each(children, (_, child) => { + $element.append(child); + }); + + return children; +}; + +export const removeDifferentElements = ($children, $newChildren) => { + each($newChildren, (__, element) => { + let hasComponent = false; + each($children, (_, oldElement) => { + if (element === oldElement) { + hasComponent = true; + } + }); + if (!hasComponent) { + element.parentNode.removeChild(element); + } + }); +}; diff --git a/js/renovation/select-box.tsx b/js/renovation/select-box.tsx new file mode 100644 index 000000000000..78d3cb751016 --- /dev/null +++ b/js/renovation/select-box.tsx @@ -0,0 +1,41 @@ +import { + Ref, Effect, Component, ComponentBindings, JSXComponent, Event, OneWay, TwoWay, +} from 'devextreme-generator/component_declaration/common'; +import { WidgetProps } from './widget'; +import DataSource, { DataSourceOptions } from '../data/data_source'; +import DxSelectBox from '../ui/select_box'; + +export const viewFunction = ({ widgetRef }: SelectBox) => (
); + +@ComponentBindings() +export class SelectBoxProps extends WidgetProps { + @OneWay() dataSource?: string | Array | DataSource | DataSourceOptions; + + @OneWay() displayExpr?: string; + + @TwoWay() value?: number; + + @OneWay() valueExpr?: string; + + @Event() valueChange?: ((value: number) => void) = () => {}; +} +@Component({ + defaultOptionRules: null, + view: viewFunction, +}) +export default class SelectBox extends JSXComponent { + @Ref() + widgetRef!: HTMLDivElement; + + @Effect() + setupWidget() { + const { valueChange } = this.props; + + new DxSelectBox(this.widgetRef, { // eslint-disable-line no-new + ...this.props as any, + onValueChanged: (e) => { + valueChange!(e.value); + }, + }); + } +} diff --git a/js/renovation/utils/base-props.tsx b/js/renovation/utils/base-props.tsx new file mode 100644 index 000000000000..07c768943c95 --- /dev/null +++ b/js/renovation/utils/base-props.tsx @@ -0,0 +1,39 @@ +import { + Event, OneWay, ComponentBindings, +} from 'devextreme-generator/component_declaration/common'; +import config from '../../core/config'; + +@ComponentBindings() +export class BaseWidgetProps { // eslint-disable-line import/prefer-default-export + @OneWay() accessKey?: string | null = null; + + @OneWay() activeStateEnabled?: boolean = false; + + @OneWay() disabled?: boolean = false; + + @OneWay() elementAttr?: { [name: string]: any }; + + @OneWay() focusStateEnabled?: boolean = false; + + @OneWay() height?: string | number | null = null; + + @OneWay() hint?: string; + + @OneWay() hoverStateEnabled?: boolean = false; + + @Event() onClick?: (e: any) => void; + + @Event({ + actionConfig: { excludeValidators: ['disabled', 'readOnly'] }, + }) onContentReady?: (e: any) => any = (() => {}); + + @Event() onKeyDown?: (e: any, options: any) => any; + + @OneWay() rtlEnabled?: boolean = config().rtlEnabled; + + @OneWay() tabIndex?: number = 0; + + @OneWay() visible?: boolean = true; + + @OneWay() width?: string | number | null = null; +} diff --git a/js/renovation/utils/noop.js b/js/renovation/utils/noop.js new file mode 100644 index 000000000000..5ff8d71cb4e4 --- /dev/null +++ b/js/renovation/utils/noop.js @@ -0,0 +1 @@ +export default () => undefined; diff --git a/js/renovation/widget.tsx b/js/renovation/widget.tsx new file mode 100644 index 000000000000..b7e4e321f696 --- /dev/null +++ b/js/renovation/widget.tsx @@ -0,0 +1,366 @@ +import { + Component, + ComponentBindings, + Effect, + Event, + InternalState, + JSXComponent, + Method, + OneWay, + Ref, + Slot, +} from 'devextreme-generator/component_declaration/common'; +import '../events/click'; +import '../events/hover'; + +import { + active, dxClick, focus, hover, keyboard, resize, visibility, +} from '../events/short'; +import { each } from '../core/utils/iterator'; +import { extend } from '../core/utils/extend'; +import { focusable } from '../ui/widget/selectors'; +import { isFakeClickEvent } from '../events/utils'; +import { BaseWidgetProps } from './utils/base-props'; + +const getStyles = ({ width, height, style }) => { + const computedWidth = typeof width === 'function' ? width() : width; + const computedHeight = typeof height === 'function' ? height() : height; + + return { + height: computedHeight ?? undefined, + width: computedWidth ?? undefined, + ...style, + }; +}; + +const setAttribute = (name, value) => { + const result = {}; + + if (value) { + const attrName = (name === 'role' || name === 'id') ? name : `aria-${name}`; + + result[attrName] = String(value); + } + + return result; +}; + +const getAria = (args) => { + let attrs = {}; + + each(args, (name, value) => { + attrs = { ...attrs, ...setAttribute(name, value) }; + }); + + return attrs; +}; + +const getAttributes = ({ elementAttr, accessKey }) => { + const attrs = extend({}, elementAttr, accessKey && { accessKey }); + + delete attrs.class; + + return attrs; +}; + +const getCssClasses = (model: Partial & Partial) => { + const className = ['dx-widget']; + const isFocusable = model.focusStateEnabled && !model.disabled; + const isHoverable = model.hoverStateEnabled && !model.disabled; + + model.classes && className.push(model.classes); + model.className && className.push(model.className); + model.disabled && className.push('dx-state-disabled'); + !model.visible && className.push('dx-state-invisible'); + model.focused && isFocusable && className.push('dx-state-focused'); + model.active && className.push('dx-state-active'); + model.hovered && isHoverable && !model.active && className.push('dx-state-hover'); + model.rtlEnabled && className.push('dx-rtl'); + model.onVisibilityChange && className.push('dx-visibility-change-handler'); + model.elementAttr?.class && className.push(model.elementAttr.class); + + return className.join(' '); +}; + +export const viewFunction = (viewModel: Widget) => ( + +); + +@ComponentBindings() +export class WidgetProps extends BaseWidgetProps { + @OneWay() _feedbackHideTimeout?: number = 400; + + @OneWay() _feedbackShowTimeout?: number = 30; + + @OneWay() activeStateUnit?: string; + + @OneWay() aria?: any = {}; + + @Slot() children?: any; + + @OneWay() classes?: string | undefined = ''; + + @OneWay() className?: string = ''; + + @OneWay() name?: string = ''; + + @Event() onActive?: (e: any) => any; + + @Event() onDimensionChanged?: () => any; + + @Event() onInactive?: (e: any) => any; + + @Event() onKeyboardHandled?: (args: any) => any | undefined; + + @Event() onVisibilityChange?: (args: boolean) => undefined; + + @OneWay() style?: { [name: string]: any }; +} + +@Component({ + defaultOptionRules: null, + jQuery: { + register: true, + }, + view: viewFunction, +}) + +export default class Widget extends JSXComponent { + @InternalState() active = false; + + @InternalState() focused = false; + + @InternalState() hovered = false; + + @Ref() + widgetRef!: HTMLDivElement; + + @Effect() + accessKeyEffect() { + const namespace = 'UIFeedback'; + const { accessKey, focusStateEnabled, disabled } = this.props; + const isFocusable = focusStateEnabled && !disabled; + const canBeFocusedByKey = isFocusable && accessKey; + + if (canBeFocusedByKey) { + dxClick.on(this.widgetRef, (e) => { + if (isFakeClickEvent(e)) { + e.stopImmediatePropagation(); + this.focused = true; + } + }, { namespace }); + + return () => dxClick.off(this.widgetRef, { namespace }); + } + + return undefined; + } + + @Effect() + activeEffect() { + const { + activeStateEnabled, activeStateUnit, disabled, onInactive, + _feedbackShowTimeout, _feedbackHideTimeout, onActive, + } = this.props; + const selector = activeStateUnit; + const namespace = 'UIFeedback'; + + if (activeStateEnabled && !disabled) { + active.on(this.widgetRef, + ({ event }) => { + this.active = true; + onActive?.(event); + }, + ({ event }) => { + this.active = false; + onInactive?.(event); + }, { + hideTimeout: _feedbackHideTimeout, + namespace, + selector, + showTimeout: _feedbackShowTimeout, + }); + + return () => active.off(this.widgetRef, { selector, namespace }); + } + + return undefined; + } + + @Effect() + clickEffect() { + const { name, onClick } = this.props; + const namespace = name; + + if (onClick) { + dxClick.on(this.widgetRef, + (e) => onClick(e), + { namespace }); + + return () => dxClick.off(this.widgetRef, { namespace }); + } + + return undefined; + } + + @Method() + focus() { + focus.trigger(this.widgetRef); + } + + @Effect() + focusEffect() { + const { disabled, focusStateEnabled, name } = this.props; + const namespace = `${name}Focus`; + const isFocusable = focusStateEnabled && !disabled; + + if (isFocusable) { + focus.on(this.widgetRef, + (e) => { !e.isDefaultPrevented() && (this.focused = true); }, + (e) => { !e.isDefaultPrevented() && (this.focused = false); }, + { + isFocusable: focusable, + namespace, + }); + + return () => focus.off(this.widgetRef, { namespace }); + } + + return undefined; + } + + @Effect() + hoverEffect() { + const namespace = 'UIFeedback'; + const { activeStateUnit, hoverStateEnabled, disabled } = this.props; + const selector = activeStateUnit; + const isHoverable = hoverStateEnabled && !disabled; + + if (isHoverable) { + hover.on(this.widgetRef, + () => { !this.active && (this.hovered = true); }, + () => { this.hovered = false; }, + { selector, namespace }); + + return () => hover.off(this.widgetRef, { selector, namespace }); + } + + return undefined; + } + + @Effect() + keyboardEffect() { + const { focusStateEnabled, onKeyDown } = this.props; + + if (focusStateEnabled || onKeyDown) { + const id = keyboard.on(this.widgetRef, this.widgetRef, + (options) => onKeyDown!(options.originalEvent, options)); + + return () => keyboard.off(id); + } + + return undefined; + } + + @Effect() + resizeEffect() { + const namespace = `${this.props.name}VisibilityChange`; + const { onDimensionChanged } = this.props; + + if (onDimensionChanged) { + resize.on(this.widgetRef, onDimensionChanged, { namespace }); + + return () => resize.off(this.widgetRef, { namespace }); + } + + return undefined; + } + + @Effect() + visibilityEffect() { + const { name, onVisibilityChange } = this.props; + const namespace = `${name}VisibilityChange`; + + if (onVisibilityChange) { + visibility.on(this.widgetRef, + () => onVisibilityChange!(true), + () => onVisibilityChange!(false), + { namespace }); + + return () => visibility.off(this.widgetRef, { namespace }); + } + + return undefined; + } + + get attributes() { + const { + accessKey, + aria, + disabled, + elementAttr, + focusStateEnabled, + visible, + } = this.props; + + const arias = getAria({ ...aria, disabled, hidden: !visible }); + const attrsWithoutClass = getAttributes({ + accessKey: focusStateEnabled && !disabled && accessKey, + elementAttr, + }); + + return { ...attrsWithoutClass, ...arias }; + } + + get styles() { + const { width, height, style } = this.props; + + return getStyles({ width, height, style }); + } + + get cssClasses() { + const { + classes, + className, + disabled, + elementAttr, + focusStateEnabled, + hoverStateEnabled, + onVisibilityChange, + rtlEnabled, + visible, + } = this.props; + + return getCssClasses({ + active: this.active, + focused: this.focused, + hovered: this.hovered, + className, + classes, + disabled, + elementAttr, + focusStateEnabled, + hoverStateEnabled, + onVisibilityChange, + rtlEnabled, + visible, + }); + } + + get tabIndex() { + const { focusStateEnabled, disabled } = this.props; + + return focusStateEnabled && !disabled && this.props.tabIndex; + } +} diff --git a/js/ui/gantt.d.ts b/js/ui/gantt.d.ts index a8d1cc9752ff..10ab5715e99b 100644 --- a/js/ui/gantt.d.ts +++ b/js/ui/gantt.d.ts @@ -238,7 +238,7 @@ export interface dxGanttStripLine { declare global { interface JQuery { dxGantt(): JQuery; - dxGantt(options: "instance"): dxGantt; + dxGantt(options: 'instance'): dxGantt; dxGantt(options: string): any; dxGantt(options: string, ...params: any[]): any; dxGantt(options: dxGanttOptions): JQuery; @@ -247,4 +247,4 @@ interface JQuery { export type Options = dxGanttOptions; /** @deprecated use Options instead */ -export type IOptions = dxGanttOptions; \ No newline at end of file +export type IOptions = dxGanttOptions; diff --git a/js/ui/grid_core/ui.grid_core.editing.js b/js/ui/grid_core/ui.grid_core.editing.js index caea082edab1..776f8c73d1a9 100644 --- a/js/ui/grid_core/ui.grid_core.editing.js +++ b/js/ui/grid_core/ui.grid_core.editing.js @@ -22,7 +22,7 @@ import Form from '../form'; import holdEvent from '../../events/hold'; import { when, Deferred, fromPromise } from '../../core/utils/deferred'; import commonUtils from '../../core/utils/common'; -import iconUtils from '../../core/utils/icon'; +import * as iconUtils from '../../core/utils/icon'; import Scrollable from '../scroll_view/ui.scrollable'; import deferredUtils from '../../core/utils/deferred'; diff --git a/js/ui/hierarchical_collection/ui.hierarchical_collection_widget.js b/js/ui/hierarchical_collection/ui.hierarchical_collection_widget.js index 8e960f24ea2e..ab91cb399866 100644 --- a/js/ui/hierarchical_collection/ui.hierarchical_collection_widget.js +++ b/js/ui/hierarchical_collection/ui.hierarchical_collection_widget.js @@ -3,7 +3,7 @@ import { compileGetter, compileSetter } from '../../core/utils/data'; import { extend } from '../../core/utils/extend'; import { each } from '../../core/utils/iterator'; import devices from '../../core/devices'; -import iconUtils from '../../core/utils/icon'; +import { getImageContainer } from '../../core/utils/icon'; import HierarchicalDataAdapter from './ui.data_adapter'; import CollectionWidget from '../collection/ui.collection_widget.edit'; import { BindableTemplate } from '../../core/templates/bindable_template'; @@ -93,7 +93,7 @@ const HierarchicalCollectionWidget = CollectionWidget.inherit({ }, _getIconContainer: function(itemData) { - return itemData.icon ? iconUtils.getImageContainer(itemData.icon) : undefined; + return itemData.icon ? getImageContainer(itemData.icon) : undefined; }, _getTextContainer: function(itemData) { diff --git a/js/ui/scheduler/ui.scheduler.js b/js/ui/scheduler/ui.scheduler.js index a15efab4de91..098b6723474e 100644 --- a/js/ui/scheduler/ui.scheduler.js +++ b/js/ui/scheduler/ui.scheduler.js @@ -50,7 +50,6 @@ import SchedulerWorkSpaceMonth from './workspaces/ui.scheduler.work_space_month' import SchedulerWorkSpaceWeek from './workspaces/ui.scheduler.work_space_week'; import SchedulerWorkSpaceWorkWeek from './workspaces/ui.scheduler.work_space_work_week'; - const when = deferredUtils.when; const Deferred = deferredUtils.Deferred; diff --git a/js/ui/widget/ui.widget.js b/js/ui/widget/ui.widget.js index e9057f0e0056..247c50986928 100644 --- a/js/ui/widget/ui.widget.js +++ b/js/ui/widget/ui.widget.js @@ -419,7 +419,7 @@ const Widget = DOMComponent.inherit({ e => this._focusInHandler(e), e => this._focusOutHandler(e), { namespace: `${this.NAME}Focus`, - isFocusable: el => $(el).is(focusableSelector) + isFocusable: (index, el) => $(el).is(focusableSelector) } ); }, diff --git a/js/ui/widget/utils.ink_ripple.js b/js/ui/widget/utils.ink_ripple.js index 24f2ee1d0385..588e631f0bae 100644 --- a/js/ui/widget/utils.ink_ripple.js +++ b/js/ui/widget/utils.ink_ripple.js @@ -10,19 +10,15 @@ const ANIMATION_DURATION = 300; const HOLD_ANIMATION_DURATION = 1000; const DEFAULT_WAVE_INDEX = 0; -const render = function(args) { - args = args || {}; - - if(args.useHoldAnimation === undefined) { - args.useHoldAnimation = true; - } +const initConfig = ({ useHoldAnimation, waveSizeCoefficient, isCentered, wavesNumber } = {}) => ({ + waveSizeCoefficient: waveSizeCoefficient || DEFAULT_WAVE_SIZE_COEFFICIENT, + isCentered: isCentered || false, + wavesNumber: wavesNumber || 1, + durations: getDurations(useHoldAnimation ?? true) +}); - const config = { - waveSizeCoefficient: args.waveSizeCoefficient || DEFAULT_WAVE_SIZE_COEFFICIENT, - isCentered: args.isCentered || false, - wavesNumber: args.wavesNumber || 1, - durations: getDurations(args.useHoldAnimation) - }; +const render = function(args) { + const config = initConfig(args); return { showWave: showWave.bind(this, config), @@ -43,7 +39,7 @@ const getInkRipple = function(element) { }; const getWaves = function(element, wavesNumber) { - const inkRipple = getInkRipple(element); + const inkRipple = getInkRipple($(element)); const result = inkRipple.children('.' + INKRIPPLE_WAVE_CLASS).toArray(); for(let i = result.length; i < wavesNumber; i++) { @@ -58,7 +54,7 @@ const getWaves = function(element, wavesNumber) { }; const getWaveStyleConfig = function(args, config) { - const element = config.element; + const element = $(config.element); const elementWidth = element.outerWidth(); const elementHeight = element.outerHeight(); const elementDiagonal = parseInt(Math.sqrt(elementWidth * elementWidth + elementHeight * elementHeight)); @@ -71,7 +67,7 @@ const getWaveStyleConfig = function(args, config) { top = (elementHeight - waveSize) / 2; } else { const event = config.event; - const position = config.element.offset(); + const position = element.offset(); const x = event.pageX - position.left; const y = event.pageY - position.top; @@ -80,8 +76,8 @@ const getWaveStyleConfig = function(args, config) { } return { - left: left, - top: top, + left, + top, height: waveSize, width: waveSize }; @@ -135,6 +131,9 @@ function hideWave(args, config) { } module.exports = { - render: render + initConfig, + hideWave, + render, + showWave }; diff --git a/package.json b/package.json index 31b7fd8f87ef..7caf12776ff3 100644 --- a/package.json +++ b/package.json @@ -40,13 +40,19 @@ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-proposal-optional-chaining": "^7.8.3", "@babel/preset-env": "^7.8.7", + "@types/enzyme": "^3.10.5", + "@types/jest": "^24.0.24", "@types/jquery": "^2.0.34", + "@types/react": "16.9.16", + "@typescript-eslint/eslint-plugin": "^2.29.0", "angular": "^1.6.10", "babel-core": "^7.0.0-bridge.0", "babel-eslint": "^10.0.3", "babel-loader": "^8.1.0", "babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", + "babel-plugin-transform-object-assign": "^6.22.0", + "babel-plugin-transform-react-jsx": "^6.24.1", "babel-preset-env": "^1.6.1", "bootstrap": "^4.3.1", "cldr-core": "latest", @@ -55,10 +61,20 @@ "cssom": "^0.4.4", "del": "^2.2.2", "devextreme-cldr-data": "^1.0.2", + "devextreme-generator": "1.0.63", "devextreme-internal-tools": "stable", - "eslint": "^7.0.0", + "enzyme": "^3.11.0", + "enzyme-adapter-preact-pure": "^2.2.0", + "eslint": "^7.1.0", + "eslint-config-airbnb-typescript": "^7.2.1", + "eslint-config-devextreme": "^0.1.1", + "eslint-plugin-import": "^2.20.2", + "eslint-plugin-jest": "^23.6.0", + "eslint-plugin-jest-formatting": "^1.2.0", + "eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-node": "^11.1.0", "eslint-plugin-qunit": "^4.0.0", + "eslint-plugin-react": "^7.18.0", "eslint-plugin-spellcheck": "0.0.11", "exceljs": "3.3.1", "fibers": "^5.0.0", @@ -91,6 +107,7 @@ "handlebars": "^4.7.3", "hogan.js": "3.0.2", "intl": "^1.2.5", + "jest": "^24.9.0", "jquery": "^3.5.1", "jquery.1": "^1.0.0", "jquery.2": "^1.0.0", @@ -108,7 +125,11 @@ "nconf": "^0.10.0", "npm-run-all": "^4.1.5", "pre-commit": "^1.2.2", + "preact": "^10.0.1", "qunitjs": "^2.0.1", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "react-test-renderer": "^16.12.0", "run-sequence": "^1.1.5", "sass": "^1.26.5", "shelljs": "^0.8.3", @@ -122,7 +143,8 @@ "systemjs-plugin-text": "0.0.8", "testcafe": "^1.2.0", "through2": "^2.0.1", - "typescript": "^2.0.3", + "ts-jest": "^24.2.0", + "typescript": "^3.7.2", "underscore": "^1.9.2", "vinyl-named": "^1.1.0", "webpack": "^3.10.0", @@ -136,19 +158,28 @@ "scripts": { "lint": "npm-run-all -p -c lint-js lint-css", "lint-js": "eslint .", + "lint-ts": "eslint ./js/renovation/*.{ts,tsx} ./js/renovation/**/*.{ts,tsx} ./testing/jest/**/*.{ts,tsx}", "lint-css": "stylelint styles", "lint-staged": "lint-staged", "build": "dotnet build build/build-dotnet.sln && gulp default", "build-dist": "dotnet build build/build-dotnet.sln && gulp default --uglify", "build-themes": "gulp style-compiler-themes", "build-themebuilder-assets": "gulp style-compiler-tb-assets", + "build:react": "gulp generate-react", + "build:react:watch": "gulp generate-react-watch", + "build:angular": "gulp generate-angular", + "build:angular:watch": "gulp generate-angular-watch", + "build:vue": "gulp generate-vue", + "build:vue:watch": "gulp generate-vue-watch", "dev": "gulp dev", "transpile-tests": "gulp transpile-tests", "test-env": "node testing/launch", "update-ts": "dx-tools update-ts-bundle --output=./ts/dx.all.d.ts", "internal-tool": "dx-tools", "validate-declarations": "dx-tools validate-declarations", - "test-functional": "node ./testing/functional/runner" + "test-functional": "node ./testing/functional/runner", + "test-jest": "jest", + "test-jest:watch": "jest --watch" }, "browserslist": [ "last 2 versions", @@ -156,5 +187,10 @@ "ie > 10", "> 1%" ], + "jest": { + "modulePathIgnorePatterns": [ + "node_modules" + ] + }, "pre-commit": "lint-staged" } diff --git a/playground/angular/README.md b/playground/angular/README.md new file mode 100644 index 000000000000..1bc90142af0b --- /dev/null +++ b/playground/angular/README.md @@ -0,0 +1,27 @@ +# DevExtreme Angular Playground + +## Run Examples + +1. Install npm packages: + + ```bash + npm install + ``` + +2. Generate components and build scripts: + + ```bash + npm run build + npm run build:angular + ``` + or + + ```bash + npm run build:angular:watch + ``` + +3. Set up a local web server and launch the application in it: + + ```bash + TODO: Add the command + ``` diff --git a/playground/angular/app/app.component.html b/playground/angular/app/app.component.html new file mode 100644 index 000000000000..a257b1fb71bd --- /dev/null +++ b/playground/angular/app/app.component.html @@ -0,0 +1,7 @@ + + diff --git a/playground/angular/app/app.component.ts b/playground/angular/app/app.component.ts new file mode 100644 index 000000000000..3ec70bb38012 --- /dev/null +++ b/playground/angular/app/app.component.ts @@ -0,0 +1,27 @@ +import { NgModule, Component } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { DxButtonModule } from 'devextreme/renovation/button'; + +@Component({ + providers: [], + selector: 'demo-app', + styleUrls: [], + templateUrl: './app/app.component.html', +}) +export class AppComponent { + onClick() { + alert('clicked'); + } +} +@NgModule({ + imports: [ + BrowserModule, + DxButtonModule, + ], + declarations: [AppComponent], + bootstrap: [AppComponent], +}) +export class AppModule {} + +platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/playground/angular/config.js b/playground/angular/config.js new file mode 100644 index 000000000000..e83fa630bddc --- /dev/null +++ b/playground/angular/config.js @@ -0,0 +1,58 @@ +/* eslint-disable */ + +System.config({ + transpiler: 'ts', + typescriptOptions: { + module: 'commonjs', + emitDecoratorMetadata: true, + experimentalDecorators: true + }, + meta: { + 'typescript': { + 'exports': 'ts' + } + }, + paths: { + 'npm:': 'https://unpkg.com/' + }, + map: { + 'ts': 'npm:plugin-typescript@8.0.0/lib/plugin.js', + 'typescript': 'npm:typescript@3.4.5/lib/typescript.js', + + '@angular/core': 'npm:@angular/core@8.0.0/bundles/core.umd.js', + '@angular/common': 'npm:@angular/common@8.0.0/bundles/common.umd.js', + '@angular/compiler': 'npm:@angular/compiler@8.0.0/bundles/compiler.umd.js', + '@angular/platform-browser': 'npm:@angular/platform-browser@8.0.0/bundles/platform-browser.umd.js', + '@angular/platform-browser-dynamic': 'npm:@angular/platform-browser-dynamic@8.0.0/bundles/platform-browser-dynamic.umd.js', + '@angular/router': 'npm:@angular/router@8.0.0/bundles/router.umd.js', + '@angular/forms': 'npm:@angular/forms@8.0.0/bundles/forms.umd.js', + '@angular/common/http': 'npm:@angular/common@8.0.0/bundles/common-http.umd.js', + 'tslib': 'npm:tslib/tslib.js', + + 'rxjs': 'npm:rxjs@6.3.3', + 'rxjs/operators': 'npm:rxjs@6.3.3/operators', + + 'jszip': 'npm:jszip@3.1.3/dist/jszip.min.js', + 'quill': 'npm:quill@1.3.6/dist/quill.js', + 'devextreme': '../../artifacts/angular' + }, + packages: { + 'app': { + main: './app.component.ts', + defaultExtension: 'ts' + }, + 'devextreme': { + defaultExtension: 'js', + }, + 'devextreme/events': { + defaultExtension: 'js', + main: 'index.js' + }, + 'devextreme/events/utils': { + defaultExtension: 'js', + main: 'index.js' + }, + 'rxjs': { main: 'index.js', defaultExtension: 'js' }, + 'rxjs/operators': { main: 'index.js', defaultExtension: 'js' }, + } +}); diff --git a/playground/angular/index.html b/playground/angular/index.html new file mode 100644 index 000000000000..ff5488eb409c --- /dev/null +++ b/playground/angular/index.html @@ -0,0 +1,32 @@ + + + + + DevExtreme Demo + + + + + + + + + + + + + + + + + + + +
+ Loading... +
+ + + diff --git a/playground/angular/tsconfig.json b/playground/angular/tsconfig.json new file mode 100644 index 000000000000..aed9b644f22b --- /dev/null +++ b/playground/angular/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "target": "es2015", + "typeRoots": [ + "node_modules/@types" + ], + "lib": [ + "es2018", + "dom" + ] + }, + "include": [ + "app/**/*.ts" + ], + "angularCompilerOptions": { + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true + } + } diff --git a/playground/angular/tslint.json b/playground/angular/tslint.json new file mode 100644 index 000000000000..2f1da5abad49 --- /dev/null +++ b/playground/angular/tslint.json @@ -0,0 +1,83 @@ +{ + "extends": "tslint:recommended", + "rules": { + "array-type": false, + "arrow-parens": false, + "deprecation": { + "severity": "warning" + }, + "component-class-suffix": true, + "contextual-lifecycle": true, + "directive-class-suffix": true, + "directive-selector": [ + true, + "attribute", + "app", + "camelCase" + ], + "component-selector": [ + true, + "element", + "app", + "kebab-case" + ], + "import-blacklist": [ + true, + "rxjs/Rx" + ], + "interface-name": false, + "max-classes-per-file": false, + "max-line-length": [ + true, + 140 + ], + "member-access": false, + "member-ordering": [ + true, + { + "order": [ + "static-field", + "instance-field", + "static-method", + "instance-method" + ] + } + ], + "no-consecutive-blank-lines": false, + "no-empty": false, + "no-inferrable-types": [ + true, + "ignore-params" + ], + "no-non-null-assertion": true, + "no-redundant-jsdoc": true, + "no-switch-case-fall-through": true, + "no-var-requires": false, + "object-literal-key-quotes": [ + true, + "as-needed" + ], + "object-literal-sort-keys": false, + "ordered-imports": false, + "quotemark": [ + true, + "single" + ], + "trailing-comma": false, + "no-conflicting-lifecycle": true, + "no-host-metadata-property": true, + "no-input-rename": true, + "no-inputs-metadata-property": true, + "no-output-native": true, + "no-output-on-prefix": true, + "no-output-rename": true, + "no-outputs-metadata-property": true, + "template-banana-in-box": true, + "template-no-negated-async": true, + "use-lifecycle-interface": true, + "use-pipe-transform-interface": true + }, + "rulesDirectory": [ + "codelyzer" + ] + } \ No newline at end of file diff --git a/playground/react/.gitignore b/playground/react/.gitignore new file mode 100644 index 000000000000..7912fc48c7d7 --- /dev/null +++ b/playground/react/.gitignore @@ -0,0 +1 @@ +/src/artifacts diff --git a/playground/react/README.md b/playground/react/README.md new file mode 100644 index 000000000000..f11f8ba1998f --- /dev/null +++ b/playground/react/README.md @@ -0,0 +1,31 @@ +# README + +## Run Example + +Install packages using the following command: + +```bash + npm install +``` + +After installation generate components and build scripts + +```bash + npm run build + npm run build:react +``` + +or + +```bash + npm run build:react:watch +``` + + +### Compiles and hot-reloads for development + +```bash +cd ./playground/react +npm install +npm start +``` diff --git a/playground/react/config/env.js b/playground/react/config/env.js new file mode 100644 index 000000000000..09ec03c5bd6a --- /dev/null +++ b/playground/react/config/env.js @@ -0,0 +1,101 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); + +// Make sure that including paths.js after env.js will read .env variables. +delete require.cache[require.resolve('./paths')]; + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + throw new Error( + 'The NODE_ENV environment variable is required but was not specified.' + ); +} + +// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +const dotenvFiles = [ + `${paths.dotenv}.${NODE_ENV}.local`, + `${paths.dotenv}.${NODE_ENV}`, + // Don't include `.env.local` for `test` environment + // since normally you expect tests to produce the same + // results for everyone + NODE_ENV !== 'test' && `${paths.dotenv}.local`, + paths.dotenv, +].filter(Boolean); + +// Load environment variables from .env* files. Suppress warnings using silent +// if this file is missing. dotenv will never modify any environment variables +// that have already been set. Variable expansion is supported in .env files. +// https://github.com/motdotla/dotenv +// https://github.com/motdotla/dotenv-expand +dotenvFiles.forEach(dotenvFile => { + if (fs.existsSync(dotenvFile)) { + require('dotenv-expand')( + require('dotenv').config({ + path: dotenvFile, + }) + ); + } +}); + +// We support resolving modules according to `NODE_PATH`. +// This lets you use absolute paths in imports inside large monorepos: +// https://github.com/facebook/create-react-app/issues/253. +// It works similar to `NODE_PATH` in Node itself: +// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders +// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. +// Otherwise, we risk importing Node.js core modules into an app instead of webpack shims. +// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 +// We also resolve them to make sure all tools using them work consistently. +const appDirectory = fs.realpathSync(process.cwd()); +process.env.NODE_PATH = (process.env.NODE_PATH || '') + .split(path.delimiter) + .filter(folder => folder && !path.isAbsolute(folder)) + .map(folder => path.resolve(appDirectory, folder)) + .join(path.delimiter); + +// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be +// injected into the application via DefinePlugin in webpack configuration. +const REACT_APP = /^REACT_APP_/i; + +function getClientEnvironment(publicUrl) { + const raw = Object.keys(process.env) + .filter(key => REACT_APP.test(key)) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + // Useful for determining whether we’re running in production mode. + // Most importantly, it switches React into the correct mode. + NODE_ENV: process.env.NODE_ENV || 'development', + // Useful for resolving the correct path to static assets in `public`. + // For example, . + // This should only be used as an escape hatch. Normally you would put + // images into the `src` and `import` them in code to get their paths. + PUBLIC_URL: publicUrl, + // We support configuring the sockjs pathname during development. + // These settings let a developer run multiple simultaneous projects. + // They are used as the connection `hostname`, `pathname` and `port` + // in webpackHotDevClient. They are used as the `sockHost`, `sockPath` + // and `sockPort` options in webpack-dev-server. + WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST, + WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, + WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, + } + ); + // Stringify all values so we can feed into webpack DefinePlugin + const stringified = { + 'process.env': Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]); + return env; + }, {}), + }; + + return { raw, stringified }; +} + +module.exports = getClientEnvironment; diff --git a/playground/react/config/getHttpsConfig.js b/playground/react/config/getHttpsConfig.js new file mode 100644 index 000000000000..013d493c1bbe --- /dev/null +++ b/playground/react/config/getHttpsConfig.js @@ -0,0 +1,66 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const chalk = require('react-dev-utils/chalk'); +const paths = require('./paths'); + +// Ensure the certificate and key provided are valid and if not +// throw an easy to debug error +function validateKeyAndCerts({ cert, key, keyFile, crtFile }) { + let encrypted; + try { + // publicEncrypt will throw an error with an invalid cert + encrypted = crypto.publicEncrypt(cert, Buffer.from('test')); + } catch (err) { + throw new Error( + `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}` + ); + } + + try { + // privateDecrypt will throw an error with an invalid key + crypto.privateDecrypt(key, encrypted); + } catch (err) { + throw new Error( + `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${ + err.message + }` + ); + } +} + +// Read file and throw an error if it doesn't exist +function readEnvFile(file, type) { + if (!fs.existsSync(file)) { + throw new Error( + `You specified ${chalk.cyan( + type + )} in your env, but the file "${chalk.yellow(file)}" can't be found.` + ); + } + return fs.readFileSync(file); +} + +// Get the https config +// Return cert files if provided in env, otherwise just true or false +function getHttpsConfig() { + const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env; + const isHttps = HTTPS === 'true'; + + if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) { + const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE); + const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE); + const config = { + cert: readEnvFile(crtFile, 'SSL_CRT_FILE'), + key: readEnvFile(keyFile, 'SSL_KEY_FILE'), + }; + + validateKeyAndCerts({ ...config, keyFile, crtFile }); + return config; + } + return isHttps; +} + +module.exports = getHttpsConfig; diff --git a/playground/react/config/jest/cssTransform.js b/playground/react/config/jest/cssTransform.js new file mode 100644 index 000000000000..8f65114812a4 --- /dev/null +++ b/playground/react/config/jest/cssTransform.js @@ -0,0 +1,14 @@ +'use strict'; + +// This is a custom Jest transformer turning style imports into empty objects. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process() { + return 'module.exports = {};'; + }, + getCacheKey() { + // The output is always the same. + return 'cssTransform'; + }, +}; diff --git a/playground/react/config/jest/fileTransform.js b/playground/react/config/jest/fileTransform.js new file mode 100644 index 000000000000..aab67618c38b --- /dev/null +++ b/playground/react/config/jest/fileTransform.js @@ -0,0 +1,40 @@ +'use strict'; + +const path = require('path'); +const camelcase = require('camelcase'); + +// This is a custom Jest transformer turning file imports into filenames. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process(src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.svg$/)) { + // Based on how SVGR generates a component name: + // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 + const pascalCaseFilename = camelcase(path.parse(filename).name, { + pascalCase: true, + }); + const componentName = `Svg${pascalCaseFilename}`; + return `const React = require('react'); + module.exports = { + __esModule: true, + default: ${assetFilename}, + ReactComponent: React.forwardRef(function ${componentName}(props, ref) { + return { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }; + }), + };`; + } + + return `module.exports = ${assetFilename};`; + }, +}; diff --git a/playground/react/config/modules.js b/playground/react/config/modules.js new file mode 100644 index 000000000000..c8efd0dd0b33 --- /dev/null +++ b/playground/react/config/modules.js @@ -0,0 +1,141 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); +const chalk = require('react-dev-utils/chalk'); +const resolve = require('resolve'); + +/** + * Get additional module paths based on the baseUrl of a compilerOptions object. + * + * @param {Object} options + */ +function getAdditionalModulePaths(options = {}) { + const baseUrl = options.baseUrl; + + // We need to explicitly check for null and undefined (and not a falsy value) because + // TypeScript treats an empty string as `.`. + if (baseUrl == null) { + // If there's no baseUrl set we respect NODE_PATH + // Note that NODE_PATH is deprecated and will be removed + // in the next major release of create-react-app. + + const nodePath = process.env.NODE_PATH || ''; + return nodePath.split(path.delimiter).filter(Boolean); + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + // We don't need to do anything if `baseUrl` is set to `node_modules`. This is + // the default behavior. + if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { + return null; + } + + // Allow the user set the `baseUrl` to `appSrc`. + if (path.relative(paths.appSrc, baseUrlResolved) === '') { + return [paths.appSrc]; + } + + // If the path is equal to the root directory we ignore it here. + // We don't want to allow importing from the root directly as source files are + // not transpiled outside of `src`. We do allow importing them with the + // absolute path (e.g. `src/Components/Button.js`) but we set that up with + // an alias. + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return null; + } + + // Otherwise, throw an error. + throw new Error( + chalk.red.bold( + "Your project's `baseUrl` can only be set to `src` or `node_modules`." + + ' Create React App does not support other values at this time.' + ) + ); +} + +/** + * Get webpack aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getWebpackAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + src: paths.appSrc, + }; + } +} + +/** + * Get jest aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getJestAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + '^src/(.*)$': '/src/$1', + }; + } +} + +function getModules() { + // Check if TypeScript is setup + const hasTsConfig = fs.existsSync(paths.appTsConfig); + const hasJsConfig = fs.existsSync(paths.appJsConfig); + + if (hasTsConfig && hasJsConfig) { + throw new Error( + 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.' + ); + } + + let config; + + // If there's a tsconfig.json we assume it's a + // TypeScript project and set up the config + // based on tsconfig.json + if (hasTsConfig) { + const ts = require(resolve.sync('typescript', { + basedir: paths.appNodeModules, + })); + config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; + // Otherwise we'll check if there is jsconfig.json + // for non TS projects. + } else if (hasJsConfig) { + config = require(paths.appJsConfig); + } + + config = config || {}; + const options = config.compilerOptions || {}; + + const additionalModulePaths = getAdditionalModulePaths(options); + + return { + additionalModulePaths: additionalModulePaths, + webpackAliases: getWebpackAliases(options), + jestAliases: getJestAliases(options), + hasTsConfig, + }; +} + +module.exports = getModules(); diff --git a/playground/react/config/paths.js b/playground/react/config/paths.js new file mode 100644 index 000000000000..b3fd764aecb3 --- /dev/null +++ b/playground/react/config/paths.js @@ -0,0 +1,72 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath'); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebook/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = relativePath => path.resolve(appDirectory, relativePath); + +// We use `PUBLIC_URL` environment variable or "homepage" field to infer +// "public path" at which the app is served. +// webpack needs to know it to put the right + + + + diff --git a/playground/vue/src/assets/logo.png b/playground/vue/src/assets/logo.png new file mode 100644 index 000000000000..f3d2503fc2a4 Binary files /dev/null and b/playground/vue/src/assets/logo.png differ diff --git a/playground/vue/src/main.js b/playground/vue/src/main.js new file mode 100644 index 000000000000..ebca04e71bb6 --- /dev/null +++ b/playground/vue/src/main.js @@ -0,0 +1,10 @@ +/* eslint-disable */ +import Vue from 'vue'; +import App from './App.vue'; + +Vue.config.productionTip = false; + +new Vue({ + render: h => h(App), +}).$mount('#app'); +/* eslint-enable */ diff --git a/playground/vue/tsconfig.json b/playground/vue/tsconfig.json new file mode 100644 index 000000000000..08c824a2e6b1 --- /dev/null +++ b/playground/vue/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "allowJs": true + } +} diff --git a/shippable.yml b/shippable.yml index 0bb2ab8e7161..62f351bc62bd 100644 --- a/shippable.yml +++ b/shippable.yml @@ -20,6 +20,7 @@ env: - TARGET=test CONSTEL=ui.scheduler TZ='Japan' - TARGET=test CONSTEL=ui.scheduler TZ='Australia/ACT' - TARGET=test CONSTEL=viz + - TARGET=test CONSTEL=renovation - TARGET=test PERF=true JQUERY=true NO_HEADLESS=true - TARGET=test MOBILE_UA=ios9 CONSTEL=ui - TARGET=test MOBILE_UA=ios9 CONSTEL=ui.editors NO_HEADLESS=true @@ -38,11 +39,13 @@ env: - TARGET=test BROWSER=firefox JQUERY=true CONSTEL=ui.grid - TARGET=test BROWSER=firefox JQUERY=true CONSTEL=ui.scheduler - TARGET=test BROWSER=firefox JQUERY=true CONSTEL=viz + - TARGET=test BROWSER=firefox JQUERY=true CONSTEL=renovation - TARGET=test_functional COMPONENT=dataGrid QUARANTINE_MODE=true - TARGET=test_functional COMPONENT=scheduler QUARANTINE_MODE=true - TARGET=test_functional COMPONENT=editors - TARGET=test_functional COMPONENT=navigation - TARGET=test_themebuilder + - TARGET=test_jest - TARGET=test_scss build: diff --git a/styles/widgets/common/button.less b/styles/widgets/common/button.less index dbce30d39b67..630ea389ac5d 100644 --- a/styles/widgets/common/button.less +++ b/styles/widgets/common/button.less @@ -55,13 +55,7 @@ } .dx-button-submit-input { - padding: 0; - margin: 0; - border: 0; - height: 0; - width: 0; - font-size: 0; - opacity: 0; + display: none; } .dx-state-disabled { diff --git a/testing/.eslintrc b/testing/.eslintrc deleted file mode 100644 index 07d11e3f3122..000000000000 --- a/testing/.eslintrc +++ /dev/null @@ -1,28 +0,0 @@ -{ - "env": { - "qunit": true, - "browser": true - }, - "globals": { - "define": true, - "Promise": true, - "SystemJS": true, - "DevExpress": true, - "sinon": true - }, - "plugins": [ - "qunit" - ], - "extends": [ - "plugin:qunit/recommended", - "plugin:qunit/two" - ], - "rules": { - "qunit/no-arrow-tests": "error", - "qunit/no-commented-tests": "error", - "qunit/no-identical-names": "warn", - "qunit/no-global-module-test": "off", - "qunit/require-expect": "off", - "qunit/resolve-async": "off" - } -} diff --git a/testing/.gitignore b/testing/.gitignore index e3820ee3572e..d644ace2da4d 100644 --- a/testing/.gitignore +++ b/testing/.gitignore @@ -1 +1,2 @@ /Results.xml +/jest/code_coverage diff --git a/testing/functional/model/dataGrid.ts b/testing/functional/model/dataGrid.ts index 9c2c86b43e8d..44ecb51be4a5 100644 --- a/testing/functional/model/dataGrid.ts +++ b/testing/functional/model/dataGrid.ts @@ -1,5 +1,5 @@ -import { ClientFunction, Selector } from "testcafe"; -import Widget from "./internal/widget"; +import { ClientFunction, Selector } from 'testcafe'; +import Widget from './internal/widget'; const CLASS = { headers: 'headers', @@ -137,7 +137,7 @@ class HeaderCell extends DxElement { } getFilterIcon(): Selector { - return this.element.find(`.dx-column-indicators > .dx-header-filter`); + return this.element.find('.dx-column-indicators > .dx-header-filter'); } } @@ -247,7 +247,7 @@ class GroupRow extends DxElement { this.widgetName = widgetName; this.isFocusedRow = this.element.hasClass(CLASS.focusedRow); this.isFocused = this.element.hasClass(CLASS.focused); - this.isExpanded = this.element.find(`.${CLASS.commandExpand} .${addWidgetPrefix(this.widgetName, CLASS.groupExpanded)}`).exists + this.isExpanded = this.element.find(`.${CLASS.commandExpand} .${addWidgetPrefix(this.widgetName, CLASS.groupExpanded)}`).exists; } getCell(index: number): DataCell { @@ -315,7 +315,7 @@ export class EditForm extends DxElement { } getItem(id): Selector { - return this.form.find(`.${CLASS.textEditorInput}[id*=_${id}]`) + return this.form.find(`.${CLASS.textEditorInput}[id*=_${id}]`); } getInvalids(): Selector { @@ -329,7 +329,7 @@ export default class DataGrid extends Widget { name: string; - constructor(id: string, name='dxDataGrid') { + constructor(id: string, name = 'dxDataGrid') { super(id); this.name = name; @@ -339,7 +339,7 @@ export default class DataGrid extends Widget { this.getGridInstance = ClientFunction( () => $(grid())[`${name}`]('instance'), - { dependencies: { grid, name }} + { dependencies: { grid, name } } ); } @@ -404,7 +404,7 @@ export default class DataGrid extends Widget { getEditForm(): EditForm { const editFormRowClass = this.addWidgetPrefix(CLASS.editFormRow); - const element = this.element ? this.element.find(`.${editFormRowClass}`) : Selector(`.${editFormRowClass}`); + const element = this.element ? this.element.find(`.${editFormRowClass}`) : Selector(`.${editFormRowClass}`); const buttons = element.find(`.${this.addWidgetPrefix(CLASS.formButtonsContainer)} .${CLASS.button}`); return new EditForm(element, buttons); @@ -478,7 +478,7 @@ export default class DataGrid extends Widget { const getGridInstance: any = this.getGridInstance; return ClientFunction(() => { const dataGrid = getGridInstance(); - const result = dataGrid.getController('validating').getCellValidationResult({ rowKey : dataGrid.getKeyByRowIndex(rowIndex), columnIndex }); + const result = dataGrid.getController('validating').getCellValidationResult({ rowKey: dataGrid.getKeyByRowIndex(rowIndex), columnIndex }); return result ? result.status : null; }, { dependencies: { getGridInstance, rowIndex, columnIndex } } )(); diff --git a/testing/functional/tests/dataGrid/keyboardNavigation.ts b/testing/functional/tests/dataGrid/keyboardNavigation.ts index b9a8f5bbc95c..d57d8d26df8f 100644 --- a/testing/functional/tests/dataGrid/keyboardNavigation.ts +++ b/testing/functional/tests/dataGrid/keyboardNavigation.ts @@ -7,7 +7,7 @@ fixture `Keyboard Navigation` .page(url(__dirname, '../container.html')); test('Cell should not highlighted after editing another cell when startEditAction: dblClick and editing.mode: batch', async t => { -const dataGrid = new DataGrid('#container'); + const dataGrid = new DataGrid('#container'); await t .expect(dataGrid.getDataCell(0, 1).isFocused).notOk() @@ -34,7 +34,7 @@ const dataGrid = new DataGrid('#container'); { name: 'Alex', phone: '555555', room: 1 }, { name: 'Dan', phone: '553355', room: 2 } ], - columns:['name','phone','room'], + columns: ['name', 'phone', 'room'], editing: { mode: 'batch', allowUpdating: true, @@ -71,7 +71,7 @@ test('Cell should highlighted after editing another cell when startEditAction is { name: 'Alex', phone: '555555', room: 1 }, { name: 'Dan', phone: '553355', room: 2 } ], - columns:['name','phone','room'], + columns: ['name', 'phone', 'room'], editing: { mode: 'cell', allowUpdating: true, @@ -117,10 +117,10 @@ test('Cell should be focused after Enter key press if enterKeyDirection is "none })); test('TextArea should be focused on editing start', async t => { - const dataGrid = new DataGrid('#container'), - commandCell = dataGrid.getDataCell(1, 3).element, - dataCell = dataGrid.getDataCell(1, 0), - getTextArea = () => dataCell.element.find('.text-area-1'); + const dataGrid = new DataGrid('#container'); + const commandCell = dataGrid.getDataCell(1, 3).element; + const dataCell = dataGrid.getDataCell(1, 0); + const getTextArea = () => dataCell.element.find('.text-area-1'); await t // act, assert @@ -366,7 +366,7 @@ test('Navigation through views using Tab, Shift+Tab', async t => { .pressKey('shift+tab') .expect(dataGrid.getDataRow(0).getCommandCell(0).getSelectCheckBox().focused).notOk() .expect(dataGrid.getDataRow(0).getCommandCell(0).element.focused).ok() - .expect(dataGrid.getDataRow(0).getCommandCell(0).isFocused).ok() + .expect(dataGrid.getDataRow(0).getCommandCell(0).isFocused).ok(); // filter row await t @@ -414,7 +414,7 @@ test('Navigation through views using Tab, Shift+Tab', async t => { .pressKey('shift+tab') .expect(Selector('BODY').focused).ok(); -}).before(async () => { +}).before(async() => { await createWidget('dxDataGrid', { width: 300, dataSource: [ @@ -454,7 +454,7 @@ test('Select - The first command cell should be focused using Tab (T884646)', as const headerRow = dataGrid.getHeaders().getHeaderRow(0); const dataRow = dataGrid.getDataRow(0); - //header row + // header row await t .pressKey('tab') .expect(headerRow.getCommandCell(0).element.focused).notOk() @@ -463,7 +463,7 @@ test('Select - The first command cell should be focused using Tab (T884646)', as .pressKey('tab') .expect(headerRow.getHeaderCell(1).element.focused).ok(); - //data row + // data row await t .pressKey('tab') .expect(dataRow.getCommandCell(0).isFocused).ok() @@ -488,7 +488,7 @@ test('Select - The first command cell should be focused using Tab (T884646)', as .expect(dataRow.getCommandCell(0).getSelectCheckBox().focused).notOk(); - //header row + // header row await t .pressKey('shift+tab') .expect(headerRow.getHeaderCell(1).element.focused).ok() @@ -502,7 +502,7 @@ test('Select - The first command cell should be focused using Tab (T884646)', as .pressKey('shift+tab') .expect(Selector('BODY').focused).ok(); -}).before(async () => { +}).before(async() => { await createWidget('dxDataGrid', { width: 300, dataSource: [ @@ -521,12 +521,12 @@ test('Edit - The first command cell should be focused using Tab (T884646)', asyn const headerRow = dataGrid.getHeaders().getHeaderRow(0); const dataRow = dataGrid.getDataRow(0); - //header row + // header row await t .pressKey('tab') .expect(headerRow.getHeaderCell(1).element.focused).ok(); - //data row + // data row await t .pressKey('tab') .expect(dataRow.getCommandCell(0).isFocused).ok() @@ -551,17 +551,17 @@ test('Edit - The first command cell should be focused using Tab (T884646)', asyn .expect(dataRow.getCommandCell(0).getButton(0).focused).notOk(); - //header row + // header row await t .pressKey('shift+tab') - .expect(headerRow.getHeaderCell(1).element.focused).ok() + .expect(headerRow.getHeaderCell(1).element.focused).ok(); // focus BODY await t .pressKey('shift+tab') .expect(Selector('BODY').focused).ok(); -}).before(async () => { +}).before(async() => { await createWidget('dxDataGrid', { width: 300, dataSource: [ @@ -584,12 +584,12 @@ test('Detail - The first command cell should be focused using Tab (T884646)', as const headerRow = dataGrid.getHeaders().getHeaderRow(0); const dataRow = dataGrid.getDataRow(0); - //header row + // header row await t .pressKey('tab') .expect(headerRow.getHeaderCell(1).element.focused).ok(); - //data row + // data row await t .pressKey('tab') .expect(dataRow.getCommandCell(0).isFocused).ok() @@ -603,17 +603,17 @@ test('Detail - The first command cell should be focused using Tab (T884646)', as .expect(dataRow.getCommandCell(0).isFocused).ok() .expect(dataRow.getCommandCell(0).element.focused).ok(); - //header row + // header row await t .pressKey('shift+tab') - .expect(headerRow.getHeaderCell(1).element.focused).ok() + .expect(headerRow.getHeaderCell(1).element.focused).ok(); // focus BODY await t .pressKey('shift+tab') .expect(Selector('BODY').focused).ok(); -}).before(async () => { +}).before(async() => { await createWidget('dxDataGrid', { width: 300, dataSource: [ @@ -631,7 +631,7 @@ test('Adaptive - Hidden cells should not be focused using Tab (T887014)', async const headerRow = dataGrid.getHeaders().getHeaderRow(0); const dataRow = dataGrid.getDataRow(0); - //header row + // header row await t .pressKey('tab') .expect(headerRow.getHeaderCell(1).element.focused).ok() @@ -643,7 +643,7 @@ test('Adaptive - Hidden cells should not be focused using Tab (T887014)', async .expect(headerRow.getHeaderCell(3).element.focused).ok() .expect(headerRow.getHeaderCell(3).element.hasAttribute('tabindex')).ok(); - //data row + // data row await t .pressKey('tab') .expect(dataRow.getCommandCell(0).isFocused).ok() @@ -669,7 +669,7 @@ test('Adaptive - Hidden cells should not be focused using Tab (T887014)', async .expect(dataRow.getCommandCell(0).isFocused).ok() .expect(dataRow.getCommandCell(0).element.focused).ok(); - //header row + // header row await t .pressKey('shift+tab') .expect(headerRow.getHeaderCell(3).element.focused).ok() @@ -679,14 +679,14 @@ test('Adaptive - Hidden cells should not be focused using Tab (T887014)', async .expect(headerRow.getHeaderCell(2).isHidden).ok() .expect(headerRow.getHeaderCell(2).element.hasAttribute('tabindex')).notOk('the third header cell does not have tabindex') .expect(headerRow.getHeaderCell(1).element.focused).ok() - .expect(headerRow.getHeaderCell(1).element.hasAttribute('tabindex')).ok() + .expect(headerRow.getHeaderCell(1).element.hasAttribute('tabindex')).ok(); // focus BODY await t .pressKey('shift+tab') .expect(Selector('BODY').focused).ok(); -}).before(async () => { +}).before(async() => { await createWidget('dxDataGrid', { keyExpr: 'name', dataSource: [ @@ -753,7 +753,7 @@ test('Select views by Ctrl+Up, Ctrl+Down keys', async t => { .pressKey('ctrl+up') .expect(headers.hasFocusedState).ok('headers has focused state') .expect(headerRow.getHeaderCell(0).element.focused).ok('focused header cell[0, 0]'); -}).before(async () => { +}).before(async() => { await createWidget('dxDataGrid', { width: 300, dataSource: [ @@ -795,7 +795,7 @@ test('DataGrid - Scroll bars should not appear when updating edge cell focus ove .pressKey('tab') .expect(dataGrid.getDataCell(1, 0).isFocused).ok() .expect(dataGrid.getScrollbarWidth(false)).eql(0); -}).before(async () => { +}).before(async() => { await createWidget('dxDataGrid', { height: 150, width: 200, @@ -831,7 +831,7 @@ test('Tab key on the focused group row should be handled by default behavior (T8 .pressKey('tab') .expect(groupRow.hasHiddenFocusState).notOk() .expect(pagerPage0.element.focused).ok(); -}).before(async () => { +}).before(async() => { await createWidget('dxDataGrid', { width: 400, dataSource: [ @@ -886,8 +886,8 @@ test('Row should not be focused by "focusedRowIndex" after change "pageIndex" by } })); -test("Cell should be highlighted after editing another cell when startEditAction is 'dblClick' and 'batch' edit mode if isHighlighted is set to true in onFocusedCellChanging (T836391)", async t => { - const dataGrid = new DataGrid("#container"); +test('Cell should be highlighted after editing another cell when startEditAction is \'dblClick\' and \'batch\' edit mode if isHighlighted is set to true in onFocusedCellChanging (T836391)', async t => { + const dataGrid = new DataGrid('#container'); const cell0 = dataGrid.getDataCell(0, 0); const cell1 = dataGrid.getDataCell(0, 1); @@ -903,12 +903,12 @@ test("Cell should be highlighted after editing another cell when startEditAction .expect(cell1.isFocused).ok() .expect(cell0.isFocused).notOk() .expect(cell0.isEditCell).notOk(); -}).before(() => createWidget("dxDataGrid", { +}).before(() => createWidget('dxDataGrid', { dataSource: [ { name: 'Alex', phone: '555555', room: 1 }, { name: 'Dan', phone: '553355', room: 2 } ], - columns:['name','phone','room'], + columns: ['name', 'phone', 'room'], editing: { mode: 'batch', allowUpdating: true, @@ -926,7 +926,7 @@ test('Previous navigation elements should not have "tabindex" if navigation acti await t .click(cell.element) .expect(cell.element.focused).ok(`cell[${rowIndex}, ${colIndex}] is focused`) - .expect(cell.element.getAttribute('tabindex')).eql('111', `cell[${rowIndex}, ${colIndex}] has tabindex`) + .expect(cell.element.getAttribute('tabindex')).eql('111', `cell[${rowIndex}, ${colIndex}] has tabindex`); } } }).before(() => createWidget('dxDataGrid', { @@ -950,7 +950,7 @@ test('Previous navigation elements should not have "tabindex" if navigation acti await t .expect(cell.element.focused).ok(`cell[${rowIndex}, ${colIndex}] is focused`) - .expect(cell.element.getAttribute('tabindex')).eql('111', `cell[${rowIndex}, ${colIndex}] has tabindex`) + .expect(cell.element.getAttribute('tabindex')).eql('111', `cell[${rowIndex}, ${colIndex}] has tabindex`); await t.pressKey('tab'); } @@ -977,13 +977,13 @@ test('The first group row should be expanded when the Enter key is pressed (T869 .pressKey('enter') - .expect(firstGroupRow.isExpanded).ok() + .expect(firstGroupRow.isExpanded).ok(); }).before(() => createWidget('dxDataGrid', { dataSource: [ { name: 'Alex', phone: '555555' } ], - columns:[{ + columns: [{ dataField: 'name', groupIndex: 0 }, 'phone'], @@ -991,4 +991,3 @@ test('The first group row should be expanded when the Enter key is pressed (T869 autoExpandAll: false } })); - diff --git a/testing/helpers/.eslintrc b/testing/helpers/.eslintrc new file mode 100644 index 000000000000..50600f0bfd79 --- /dev/null +++ b/testing/helpers/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "devextreme/qunit" + ] +} diff --git a/testing/helpers/qunitExtensions.js b/testing/helpers/qunitExtensions.js index 33fd2d49e5ec..f87330cc3f18 100644 --- a/testing/helpers/qunitExtensions.js +++ b/testing/helpers/qunitExtensions.js @@ -424,6 +424,9 @@ return true; } } + + if(callback.match(/function\(\)\{clearTimeout\(\w+\),cancelAnimationFrame\(\w+\),setTimeout\(\w+\)\}/)) return true; // NOTE: Preact hooks + if(callback.match(/\.__H\.\w+\.forEach\(/)) return true; // NOTE: Preact hooks }); const logTestFailure = function(timerInfo) { diff --git a/testing/jest/.eslintrc b/testing/jest/.eslintrc new file mode 100644 index 000000000000..72d208766563 --- /dev/null +++ b/testing/jest/.eslintrc @@ -0,0 +1,8 @@ +{ + "extends": [ + "devextreme/jest" + ], + "settings": { + "react": { "pragma": "h" } + } +} diff --git a/testing/jest/button.tests.tsx b/testing/jest/button.tests.tsx new file mode 100644 index 000000000000..57ef06d24fb0 --- /dev/null +++ b/testing/jest/button.tests.tsx @@ -0,0 +1,687 @@ + +import { h, createRef } from 'preact'; +import { mount, ReactWrapper } from 'enzyme'; +import { JSXInternal } from 'preact/src/jsx'; +import devices from '../../js/core/devices'; +import themes from '../../js/ui/themes'; +import { + clear as clearEventHandlers, + defaultEvent, + emit, + emitKeyboard, + getEventHandlers, + fakeClickEvent, + EVENT, + KEY, +} from './utils/events-mock'; +import Button, { defaultOptions } from '../../js/renovation/button.p'; +import type ButtonRef from '../../js/renovation/button.p'; +import Icon from '../../js/renovation/icon.p'; +import Widget from '../../js/renovation/widget.p'; +import type { WidgetProps } from '../../js/renovation/widget'; +import type { ButtonProps } from '../../js/renovation/button'; + +type Mock = jest.Mock; + +jest.mock('../../js/core/devices', () => { + const actualDevices = require.requireActual('../../js/core/devices'); + const isSimulator = actualDevices.isSimulator.bind(actualDevices); + const real = actualDevices.real.bind(actualDevices); + + actualDevices.isSimulator = jest.fn(isSimulator); + actualDevices.real = jest.fn(real); + + return actualDevices; +}); + +jest.mock('../../js/ui/themes', () => ({ + ...require.requireActual('../../js/ui/themes'), + current: jest.fn(() => 'generic'), +})); + +describe('Button', () => { + const render = (props = {}): ReactWrapper => mount(