diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index cdc7a9025584..e5bd8d265f3a 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -435,26 +435,58 @@ jobs: path: apps/demos continue-on-error: true + - name: Detect changed React TS demos + id: changed-react-demos + working-directory: apps/demos + run: | + if [ ! -f "changed-files.json" ]; then + echo "changed-files.json not found, skipping generated JS demos check" + echo "has-react-demos=false" >> $GITHUB_OUTPUT + exit 0 + fi + + jq -r '.[].filename' changed-files.json \ + | grep '/React/' \ + | grep -E '\.tsx?$' \ + | sed 's|^apps/demos/||' \ + | sed -E 's|/[^/]*\.tsx?$||' \ + | sort \ + | uniq > changed-react-demos.txt || true + + if [ -s changed-react-demos.txt ]; then + echo "Changed React demos:" + cat changed-react-demos.txt + echo "has-react-demos=true" >> $GITHUB_OUTPUT + else + echo "No React demos found in changed files, skipping conversion" + echo "has-react-demos=false" >> $GITHUB_OUTPUT + fi + - uses: pnpm/action-setup@v6 + if: steps.changed-react-demos.outputs.has-react-demos == 'true' with: run_install: false - name: Use Node.js + if: steps.changed-react-demos.outputs.has-react-demos == 'true' uses: actions/setup-node@v6 with: node-version-file: '.node-version' - name: Download devextreme sources + if: steps.changed-react-demos.outputs.has-react-demos == 'true' uses: actions/download-artifact@v8 with: name: devextreme-sources - name: Get pnpm store directory + if: steps.changed-react-demos.outputs.has-react-demos == 'true' shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - uses: actions/cache/restore@v5 + if: steps.changed-react-demos.outputs.has-react-demos == 'true' name: Restore pnpm cache with: path: ${{ env.STORE_PATH }} @@ -463,38 +495,25 @@ jobs: ${{ runner.os }}-pnpm-cache - name: Install dependencies + if: steps.changed-react-demos.outputs.has-react-demos == 'true' run: pnpm install --frozen-lockfile - name: Install tgz + if: steps.changed-react-demos.outputs.has-react-demos == 'true' working-directory: apps/demos run: pnpm add ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz - name: Prepare JS + if: steps.changed-react-demos.outputs.has-react-demos == 'true' working-directory: apps/demos run: pnpm run prepare-js - name: Check generated JS demos + if: steps.changed-react-demos.outputs.has-react-demos == 'true' working-directory: apps/demos run: | - if [ -f "changed-files.json" ]; then - echo "Running convert-to-js on changed files only" - - CHANGED_DEMOS=$(jq -r '.[].filename' changed-files.json | grep '/React/' | grep '\.tsx$' | sed 's|^apps/demos/||' | sed 's|/[^/]*\.tsx$||' | sort | uniq) - - if [ -z "$CHANGED_DEMOS" ]; then - echo "No React demos found in changed files, skipping conversion" - else - echo "Changed React demos:" - echo "$CHANGED_DEMOS" - - echo "$CHANGED_DEMOS" | while read -r demo_dir; do - if [ ! -z "$demo_dir" ]; then - echo "Converting: $demo_dir" - pnpm run convert-to-js "$demo_dir" - fi - done - fi - fi + echo "Running convert-to-js on changed files only" + xargs -r pnpm run convert-to-js < changed-react-demos.txt git add ./Demos -N @@ -520,7 +539,7 @@ jobs: strategy: fail-fast: false matrix: - CONSTEL: ['1/5', '2/5', '3/5', '4/5', '5/5'] + CONSTEL: ['1/2', '2/2'] steps: - name: Get sources @@ -1300,4 +1319,3 @@ jobs: name: csp-violations-report path: apps/demos/csp-reports/ if-no-files-found: ignore - diff --git a/apps/demos/Demos/Diagram/AdvancedDataBinding/React/App.tsx b/apps/demos/Demos/Diagram/AdvancedDataBinding/React/App.tsx index c000ee118b3e..0453eabd0ad4 100644 --- a/apps/demos/Demos/Diagram/AdvancedDataBinding/React/App.tsx +++ b/apps/demos/Demos/Diagram/AdvancedDataBinding/React/App.tsx @@ -46,7 +46,7 @@ function itemTextStyleExpr(obj: { level: string; }) { } function itemStyleExpr(obj: { type: string; }) { - const style: React.CSSProperties = { stroke: '#444444' }; + const style: { stroke: string; fill?: string } = { stroke: '#444444' }; if (obj.type === 'group') { style.fill = '#f3f3f3'; } diff --git a/apps/demos/Demos/VectorMap/ImageMarkers/React/devextreme-vectormap-data.d.ts b/apps/demos/Demos/VectorMap/ImageMarkers/React/devextreme-vectormap-data.d.ts new file mode 100644 index 000000000000..e5de5ff1844d --- /dev/null +++ b/apps/demos/Demos/VectorMap/ImageMarkers/React/devextreme-vectormap-data.d.ts @@ -0,0 +1,27 @@ +declare module 'devextreme-dist/js/vectormap-data/usa.js' { + type Position = number[]; + type Geometry = + | { type: 'Point'; coordinates: Position } + | { type: 'MultiPoint'; coordinates: Position[] } + | { type: 'LineString'; coordinates: Position[] } + | { type: 'MultiLineString'; coordinates: Position[][] } + | { type: 'Polygon'; coordinates: Position[][] } + | { type: 'MultiPolygon'; coordinates: Position[][][] }; + + interface Feature { + type: 'Feature'; + geometry: Geometry; + properties: Record; + } + + interface FeatureCollection { + type: 'FeatureCollection'; + features: Feature[]; + // eslint-disable-next-line spellcheck/spell-checker + bbox?: number[]; + } + + const usa: FeatureCollection; + export { usa }; + export default usa; +} diff --git a/apps/demos/Demos/VectorMap/MultipleLayers/React/devextreme-vectormap-data.d.ts b/apps/demos/Demos/VectorMap/MultipleLayers/React/devextreme-vectormap-data.d.ts new file mode 100644 index 000000000000..370ec277e5ea --- /dev/null +++ b/apps/demos/Demos/VectorMap/MultipleLayers/React/devextreme-vectormap-data.d.ts @@ -0,0 +1,27 @@ +declare module 'devextreme-dist/js/vectormap-data/world.js' { + type Position = number[]; + type Geometry = + | { type: 'Point'; coordinates: Position } + | { type: 'MultiPoint'; coordinates: Position[] } + | { type: 'LineString'; coordinates: Position[] } + | { type: 'MultiLineString'; coordinates: Position[][] } + | { type: 'Polygon'; coordinates: Position[][] } + | { type: 'MultiPolygon'; coordinates: Position[][][] }; + + interface Feature { + type: 'Feature'; + geometry: Geometry; + properties: Record; + } + + interface FeatureCollection { + type: 'FeatureCollection'; + features: Feature[]; + // eslint-disable-next-line spellcheck/spell-checker + bbox?: number[]; + } + + const world: FeatureCollection; + export { world }; + export default world; +} diff --git a/apps/demos/utils/ts-to-js-converter/cli.ts b/apps/demos/utils/ts-to-js-converter/cli.ts index a15262238887..fec37f8784e1 100644 --- a/apps/demos/utils/ts-to-js-converter/cli.ts +++ b/apps/demos/utils/ts-to-js-converter/cli.ts @@ -5,7 +5,19 @@ import { glob } from 'glob'; import { consola } from 'consola'; import fs from 'fs'; -import { converter } from './converter'; +import { converter, prettifyOutputs, splitArrayIntoSubarrays } from './converter'; +import { ActionConverterEntry } from './types'; + +const defaultConversionConcurrency = 8; + +const logger = { + warning: consola.warn, + error: consola.error, + debug: consola.debug, + info: consola.info, + start: consola.start, + success: consola.success, +}; function findFoldersWithTsxFiles(directory) { const foldersWithTsxFiles = []; @@ -51,16 +63,7 @@ const getPatterns = () => { return filteredDemos.map((demoName) => demoName.split(path.sep).join(path.posix.sep)); }; -const performConversion = async (patterns) => { - const logger = { - warning: consola.warn, - error: consola.error, - debug: consola.debug, - info: consola.info, - start: consola.start, - success: consola.success, - }; - +const performConversion = async (patterns, conversionConcurrency) => { const args = minimist(patterns); const sourceDirs = args._ || [process.cwd()]; @@ -81,57 +84,82 @@ const performConversion = async (patterns) => { // @ts-ignore )).flat(1); - await Promise.all( - entries.map(async ({ source, out }) => { - logger.start(`converting ${source}`); - await converter(source, out, logger); - logger.success(`${source} complete`); - }), - ) - // eslint-disable-next-line no-void - .then(void 0) - .catch((error) => { - logger.error(error); - process.exit(1); - }); + const outDirs: (string | null)[] = []; + let failedCount = 0; + const entryBatches = splitArrayIntoSubarrays( + entries, + conversionConcurrency, + ); + + for (const entryBatch of entryBatches) { + outDirs.push(...await Promise.all( + entryBatch.map(async ({ source, out }) => { + logger.start(`converting ${source}`); + try { + const converted = await converter(source, out, logger); + if (converted) { + logger.success(`${source} complete`); + } + return converted ? out : null; + } catch { + logger.error(`failed converting ${source}`); + failedCount += 1; + return null; + } + }), + )); + } + + return { + outDirs: outDirs.filter((outDir): outDir is string => outDir != null), + failedCount, + }; }; -function splitArrayIntoSubarrays(array, subarrayLength) { - const result = []; +function getConversionConcurrency() { + const rawValue = process.env.CONVERT_TO_JS_CONCURRENCY; + const parsedValue = rawValue == null ? defaultConversionConcurrency : Number(rawValue); - for (let i = 0; i < array.length; i += subarrayLength) { - result.push(array.slice(i, i + subarrayLength)); + if (!Number.isInteger(parsedValue) || parsedValue < 1) { + throw new Error(`CONVERT_TO_JS_CONCURRENCY must be a positive integer. Received: ${rawValue}`); } - return result; + return parsedValue; } async function startScript() { const userFlags = process.argv.slice(2); - if (userFlags[0] === 'split') { - process.env.CONSTEL = '1/4'; - consola.log('Start converting Part', process.env.CONSTEL); - await batchPatternsAndConvert(); - process.env.CONSTEL = '2/4'; - consola.log('Start converting Part', process.env.CONSTEL); - await batchPatternsAndConvert(); - process.env.CONSTEL = '3/4'; - consola.log('Start converting Part', process.env.CONSTEL); - await batchPatternsAndConvert(); - process.env.CONSTEL = '4/4'; - consola.log('Start converting Part', process.env.CONSTEL); - await batchPatternsAndConvert(); - } else { - await batchPatternsAndConvert(); + const parts = userFlags[0] === 'split' ? ['1/4', '2/4', '3/4', '4/4'] : [null]; + let failedCount = 0; + + for (const part of parts) { + if (part != null) { + process.env.CONSTEL = part; + consola.log('Start converting Part', process.env.CONSTEL); + } + failedCount += await batchPatternsAndConvert(); } + + return failedCount; } async function batchPatternsAndConvert() { const allPatterns = getPatterns(); - const batches = splitArrayIntoSubarrays(allPatterns, 10); - for (const batch of batches) { - await performConversion(batch); - } + const conversionConcurrency = getConversionConcurrency(); + const { outDirs, failedCount } = await performConversion(allPatterns, conversionConcurrency); + + await prettifyOutputs(outDirs, process.cwd(), logger); + + return failedCount; } -startScript(); +startScript() + .then((failedCount) => { + if (failedCount > 0) { + process.exit(1); + } + }) + .catch((error) => { + logger.error(error); + process.exit(1); + }); diff --git a/apps/demos/utils/ts-to-js-converter/converter.ts b/apps/demos/utils/ts-to-js-converter/converter.ts index cd7319aacb69..1b215c54aaba 100644 --- a/apps/demos/utils/ts-to-js-converter/converter.ts +++ b/apps/demos/utils/ts-to-js-converter/converter.ts @@ -9,7 +9,11 @@ import { promisify } from 'util'; import _ from 'lodash'; import os from 'os'; -import { Logger, PathResolver, PathResolvers } from './types'; +import { + Logger, + PathResolver, + PathResolvers, +} from './types'; let platformGlob = glob; const makePathArrayPosix = (pathArray) => pathArray.map( @@ -34,6 +38,20 @@ const bundleAssets = [ const redundantAssets = ['*tsconfig*', 'types.js']; +const quoteShellArg = (value: string) => `"${value.replace(/(["\\$`])/g, '\\$1')}"`; + +const toPosixPath = (value: string) => value.split(path.sep).join(path.posix.sep); + +export function splitArrayIntoSubarrays(array: T[], subarrayLength: number): T[][] { + const result: T[][] = []; + + for (let i = 0; i < array.length; i += subarrayLength) { + result.push(array.slice(i, i + subarrayLength)); + } + + return result; +} + const makeConfig = ( resolve: PathResolvers, include: string[], @@ -59,6 +77,7 @@ const makeConfig = ( skipLibCheck: true, allowSyntheticDefaultImports: true, resolveJsonModule: true, + preserveSymlinks: true, }, }); @@ -80,12 +99,26 @@ const pipeSource = async ( const execTsc = async (directory: string, args: string[]): Promise => { const tscScript = require.resolve('typescript/bin/tsc'); - const { stdout } = await promisify(cps.execFile)( - process.execPath, - [tscScript, ...args], - { cwd: directory }, - ); - return stdout; + try { + const { stdout } = await promisify(cps.execFile)( + process.execPath, + [tscScript, ...args], + { cwd: directory }, + ); + return stdout; + } catch (error) { + const { stdout, stderr } = error as { stdout?: string; stderr?: string }; + + if (stdout) { + console.error(stdout); + } + + if (stderr) { + console.error(stderr); + } + + throw error; + } }; const compile = async (resolve: PathResolvers, log: Logger) => { @@ -182,14 +215,68 @@ const patchImports = async (resolve: PathResolvers, log: Logger) => { log.debug('imports patching done'); }; -const prettify = async (resolve: PathResolvers, log: Logger) => { +const PRETTIFY_CHUNK_SIZE = 50; + +const formatOutputs = async ( + outDirs: string[], + demosRootDir: string, + prettierCwd: string, + prettierConfig: string, + eslintConfig: string, +) => { + const prettierPatterns = outDirs + .map((outDir) => quoteShellArg(`${path.resolve(outDir)}${path.sep}!(*.{css,json,md,tsbuildinfo})`)) + .join(' '); + const eslintPatterns = outDirs + .map((outDir) => quoteShellArg( + toPosixPath(path.relative(demosRootDir, outDir)), + )) + .join(' '); + + const prettierCommand = [ + 'prettier', + '--write', + prettierPatterns, + '--config', + prettierConfig, + '--single-attribute-per-line', + '--print-width 100', + ].join(' '); + const eslintCommand = [ + 'eslint', + '--fix', + eslintPatterns, + '--config', + eslintConfig, + '--ignore-pattern "**/config.js"', + '--ignore-pattern "*.tsbuildinfo"', + ].join(' '); + + await exec(prettierCommand, { cwd: prettierCwd }); + await exec(eslintCommand, { cwd: demosRootDir }); +}; + +export const prettifyOutputs = async ( + outDirs: string[], + demosRootDir: string, + log: Logger, +) => { + const uniqueOutDirs = [...new Set(outDirs)]; + + if (uniqueOutDirs.length === 0) { + return; + } + log.debug('running Prettier'); - await exec(`prettier --write "${resolve.out('')}${path.sep}!(*.{css,json,md,tsbuildinfo})" --single-attribute-per-line --print-width 100`, { - cwd: resolve.out(''), - }); - await exec(`eslint --fix "${resolve.out('')}" --ignore-pattern "config.js" --ignore-pattern "*.tsbuildinfo"`, { - cwd: resolve.out(''), - }); + + const prettierConfig = quoteShellArg(path.join(demosRootDir, '.prettierrc.json')); + const eslintConfig = quoteShellArg(path.join(demosRootDir, 'eslint.config.mjs')); + + const prettierCwd = uniqueOutDirs[0]; + + for (const chunk of splitArrayIntoSubarrays(uniqueOutDirs, PRETTIFY_CHUNK_SIZE)) { + await formatOutputs(chunk, demosRootDir, prettierCwd, prettierConfig, eslintConfig); + } }; const hasTypescriptFiles = async (resolve: PathResolver) => { @@ -203,7 +290,7 @@ export const converter = async ( sourceDir: string, outDir: string, log: Logger, -): Promise => { +): Promise => { log.debug('TS to JS example converter starting'); log.debug(`sourceDir: ${sourceDir}`); log.debug(`outDir: ${outDir}`); @@ -218,7 +305,7 @@ export const converter = async ( if (!await hasTypescriptFiles(sourceDirResolver)) { log.info(`No TypeScript files found in ${sourceDir}. Skipping...`); - return; + return false; } log.debug(`touching ${outDir}`); @@ -238,11 +325,8 @@ export const converter = async ( await compile(resolve, log); await copyAssets(resolve, log); await patchImports(resolve, log); - await prettify(resolve, log); await strip(resolve, log); - } catch (error) { - log.error(error); - return; + return true; } finally { log.debug(`removing temp directory: ${tempDir}`); await remove(tempDir);