chore: initial deploy for code base
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Config file for API Extractor. For more info, please visit: https://api-extractor.com
|
||||
*/
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
/**
|
||||
* Optionally specifies another JSON config file that this file extends from. This provides a way for
|
||||
* standard settings to be shared across multiple projects.
|
||||
*
|
||||
* If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains
|
||||
* the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be
|
||||
* resolved using NodeJS require().
|
||||
*
|
||||
* SUPPORTED TOKENS: none
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "extends": "./shared/api-extractor-base.json"
|
||||
// "extends": "my-package/include/api-extractor-base.json"
|
||||
/**
|
||||
* Determines the "<projectFolder>" token that can be used with other config file settings. The project folder
|
||||
* typically contains the tsconfig.json and package.json config files, but the path is user-defined.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting.
|
||||
*
|
||||
* The default value for "projectFolder" is the token "<lookup>", which means the folder is determined by traversing
|
||||
* parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder
|
||||
* that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error
|
||||
* will be reported.
|
||||
*
|
||||
* SUPPORTED TOKENS: <lookup>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
// "projectFolder": "..",
|
||||
/**
|
||||
* (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor
|
||||
* analyzes the symbols exported by this module.
|
||||
*
|
||||
* The file extension must be ".d.ts" and not ".ts".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
*/
|
||||
"mainEntryPointFilePath": "<projectFolder>/lib/index.d.ts",
|
||||
/**
|
||||
* A list of NPM package names whose exports should be treated as part of this package.
|
||||
*
|
||||
* For example, suppose that Webpack is used to generate a distributed bundle for the project "library1",
|
||||
* and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part
|
||||
* of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly
|
||||
* imports library2. To avoid this, we can specify:
|
||||
*
|
||||
* "bundledPackages": [ "library2" ],
|
||||
*
|
||||
* This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been
|
||||
* local files for library1.
|
||||
*/
|
||||
"bundledPackages": [],
|
||||
/**
|
||||
* Determines how the TypeScript compiler engine will be invoked by API Extractor.
|
||||
*/
|
||||
"compiler": {
|
||||
/**
|
||||
* Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* Note: This setting will be ignored if "overrideTsconfig" is used.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/tsconfig.json"
|
||||
*/
|
||||
// "tsconfigFilePath": "<projectFolder>/tsconfig.json",
|
||||
/**
|
||||
* Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk.
|
||||
* The object must conform to the TypeScript tsconfig schema:
|
||||
*
|
||||
* http://json.schemastore.org/tsconfig
|
||||
*
|
||||
* If omitted, then the tsconfig.json file will be read from the "projectFolder".
|
||||
*
|
||||
* DEFAULT VALUE: no overrideTsconfig section
|
||||
*/
|
||||
// "overrideTsconfig": {
|
||||
// . . .
|
||||
// }
|
||||
/**
|
||||
* This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended
|
||||
* and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when
|
||||
* dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses
|
||||
* for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "skipLibCheck": true,
|
||||
},
|
||||
/**
|
||||
* Configures how the API report file (*.api.md) will be generated.
|
||||
*/
|
||||
"apiReport": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate an API report.
|
||||
*/
|
||||
"enabled": true,
|
||||
/**
|
||||
* The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce
|
||||
* a full file path.
|
||||
*
|
||||
* The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/".
|
||||
*
|
||||
* SUPPORTED TOKENS: <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<unscopedPackageName>.api.md"
|
||||
*/
|
||||
// "reportFileName": "<unscopedPackageName>.api.md",
|
||||
/**
|
||||
* Specifies the folder where the API report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy,
|
||||
* e.g. for an API review.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/etc/"
|
||||
*/
|
||||
// "reportFolder": "<projectFolder>/etc/",
|
||||
/**
|
||||
* Specifies the folder where the temporary report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* After the temporary file is written to disk, it is compared with the file in the "reportFolder".
|
||||
* If they are different, a production build will fail.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/"
|
||||
*/
|
||||
"reportTempFolder": "<projectFolder>/lib/"
|
||||
},
|
||||
/**
|
||||
* Configures how the doc model file (*.api.json) will be generated.
|
||||
*/
|
||||
"docModel": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate a doc model file.
|
||||
*/
|
||||
"enabled": true,
|
||||
/**
|
||||
* The output path for the doc model file. The file extension should be ".api.json".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/<unscopedPackageName>.api.json"
|
||||
*/
|
||||
"apiJsonFilePath": "<projectFolder>/lib/<unscopedPackageName>.api.json"
|
||||
},
|
||||
/**
|
||||
* Configures how the .d.ts rollup file will be generated.
|
||||
*/
|
||||
"dtsRollup": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate the .d.ts rollup file.
|
||||
*/
|
||||
"enabled": true,
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated without any trimming.
|
||||
* This file will include all declarations that are exported by the main entry point.
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/dist/<unscopedPackageName>.d.ts"
|
||||
*/
|
||||
"untrimmedFilePath": "<projectFolder>/lib/<unscopedPackageName>.d.ts"
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release.
|
||||
* This file will include only declarations that are marked as "@public" or "@beta".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts",
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release.
|
||||
* This file will include only declarations that are marked as "@public".
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "publicTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-public.d.ts",
|
||||
/**
|
||||
* When a declaration is trimmed, by default it will be replaced by a code comment such as
|
||||
* "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the
|
||||
* declaration completely.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "omitTrimmingComments": true
|
||||
},
|
||||
/**
|
||||
* Configures how the tsdoc-metadata.json file will be generated.
|
||||
*/
|
||||
"tsdocMetadata": {
|
||||
/**
|
||||
* Whether to generate the tsdoc-metadata.json file.
|
||||
*
|
||||
* DEFAULT VALUE: true
|
||||
*/
|
||||
// "enabled": true,
|
||||
/**
|
||||
* Specifies where the TSDoc metadata file should be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* The default value is "<lookup>", which causes the path to be automatically inferred from the "tsdocMetadata",
|
||||
* "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup
|
||||
* falls back to "tsdoc-metadata.json" in the package folder.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
// "tsdocMetadataFilePath": "<projectFolder>/dist/tsdoc-metadata.json"
|
||||
},
|
||||
/**
|
||||
* Configures how API Extractor reports error and warning messages produced during analysis.
|
||||
*
|
||||
* There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages.
|
||||
*/
|
||||
"messages": {
|
||||
/**
|
||||
* Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing
|
||||
* the input .d.ts files.
|
||||
*
|
||||
* TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"compilerMessageReporting": {
|
||||
/**
|
||||
* Configures the default routing for messages that don't match an explicit rule in this table.
|
||||
*/
|
||||
"default": {
|
||||
/**
|
||||
* Specifies whether the message should be written to the the tool's output log. Note that
|
||||
* the "addToApiReportFile" property may supersede this option.
|
||||
*
|
||||
* Possible values: "error", "warning", "none"
|
||||
*
|
||||
* Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail
|
||||
* and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes
|
||||
* the "--local" option), the warning is displayed but the build will not fail.
|
||||
*
|
||||
* DEFAULT VALUE: "warning"
|
||||
*/
|
||||
"logLevel": "none",
|
||||
/**
|
||||
* When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md),
|
||||
* then the message will be written inside that file; otherwise, the message is instead logged according to
|
||||
* the "logLevel" option.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "addToApiReportFile": false
|
||||
},
|
||||
// "TS2551": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
/**
|
||||
* Configures handling of messages reported by API Extractor during its analysis.
|
||||
*
|
||||
* API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag"
|
||||
*
|
||||
* DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings
|
||||
*/
|
||||
"extractorMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "none",
|
||||
// "addToApiReportFile": false
|
||||
},
|
||||
// "ae-extra-release-tag": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
/**
|
||||
* Configures handling of messages reported by the TSDoc parser when analyzing code comments.
|
||||
*
|
||||
* TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"tsdocMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "none",
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
// "tsdoc-link-tag-unescaped-text": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const babelOptions = require('@strata/babel');
|
||||
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
|
||||
return babelOptions();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const rucksack = require('rucksack-css');
|
||||
const autoprefixer = require('autoprefixer');
|
||||
|
||||
module.exports = {
|
||||
plugins: [rucksack(), autoprefixer()],
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
const less = require('less');
|
||||
const { readFileSync } = require('fs');
|
||||
const path = require('path');
|
||||
const postcss = require('postcss');
|
||||
const NpmImportPlugin = require('less-plugin-npm-import');
|
||||
const postcssConfig = require('./postcssConfig');
|
||||
|
||||
function transformLess(lessFile, config = {}) {
|
||||
const { cwd = process.cwd() } = config;
|
||||
const resolvedLessFile = path.resolve(cwd, lessFile);
|
||||
|
||||
let data = readFileSync(resolvedLessFile, 'utf-8');
|
||||
data = data.replace(/^\uFEFF/, '');
|
||||
|
||||
// Do less compile
|
||||
const lessOpts = {
|
||||
paths: [path.dirname(resolvedLessFile)],
|
||||
filename: resolvedLessFile,
|
||||
plugins: [new NpmImportPlugin({ prefix: '~' })],
|
||||
javascriptEnabled: true,
|
||||
};
|
||||
return less
|
||||
.render(data, lessOpts)
|
||||
.then(result => postcss(postcssConfig.plugins).process(result.css, { from: undefined }))
|
||||
.then(r => r.css);
|
||||
}
|
||||
|
||||
module.exports = transformLess;
|
||||
+2192
File diff suppressed because it is too large
Load Diff
+160
@@ -0,0 +1,160 @@
|
||||
const { watch, series, parallel } = require('gulp');
|
||||
const gulp = require('gulp');
|
||||
|
||||
/** JS and TS */
|
||||
const ts = require('gulp-typescript');
|
||||
const apiExtractor = require('@microsoft/api-extractor');
|
||||
|
||||
const tsDefaultReporter = ts.reporter.defaultReporter();
|
||||
const babel = require('gulp-babel');
|
||||
const sourcemaps = require('gulp-sourcemaps');
|
||||
|
||||
/** css modules */
|
||||
const gulp_tcm = require('gulp-typed-css-modules');
|
||||
const autoprefixer = require('autoprefixer')
|
||||
const postcss = require('gulp-postcss')
|
||||
const compileSass = require('gulp-sass')(require('sass'));
|
||||
const cleanCSS = require('gulp-clean-css');
|
||||
|
||||
/** file */
|
||||
const concat = require('gulp-concat');
|
||||
const rimraf = require('rimraf');
|
||||
const through2 = require("through2");
|
||||
const merge2 = require('merge2');
|
||||
const path = require('path');
|
||||
const compileLess = require('./buildTools/transformLess');
|
||||
const babelConfig = require('./babel.config.js');
|
||||
|
||||
const cwd = process.cwd();
|
||||
const dir = path.join(cwd, './lib/');
|
||||
|
||||
/** functions */
|
||||
function typedCss() {
|
||||
return gulp.src([`${dir}/**/*.css`], {
|
||||
base: '.',
|
||||
})
|
||||
.pipe(gulp_tcm({ quiet: true }))
|
||||
.pipe(gulp.dest("./"));
|
||||
}
|
||||
|
||||
function autoprefixCss() {
|
||||
return gulp.src([`${dir}/**/*.css`], {
|
||||
base: '.',
|
||||
})
|
||||
.pipe(postcss([autoprefixer()]))
|
||||
.pipe(gulp.dest('./'))
|
||||
}
|
||||
|
||||
// Sass must come before Less
|
||||
function combineComponentsCss() {
|
||||
return gulp.src([`${dir}/styles/ComponentsSass.css`, `${dir}/styles/ComponentsLess.css`])
|
||||
.pipe(concat('Components.css'))
|
||||
.pipe(cleanCSS())
|
||||
.pipe(gulp.dest(`${dir}/styles/`));
|
||||
}
|
||||
|
||||
// Sass must come before Less
|
||||
function combineBaseCss() {
|
||||
return gulp.src([`${dir}/styles/BaseSass.css`, `${dir}/styles/BaseLess.css`])
|
||||
.pipe(concat('Base.css'))
|
||||
.pipe(cleanCSS())
|
||||
.pipe(gulp.dest(`${dir}/styles/`));
|
||||
}
|
||||
|
||||
function compile() {
|
||||
rimraf.sync(dir);
|
||||
const less = gulp
|
||||
.src(['src/**/*.less'])
|
||||
.pipe(
|
||||
through2.obj(function (file, encoding, next) {
|
||||
this.push(file.clone());
|
||||
|
||||
compileLess(file.path)
|
||||
.then(css => {
|
||||
file.contents = Buffer.from(css);
|
||||
file.path = file.path.replace(/\.less$/, '.css');
|
||||
this.push(file);
|
||||
next();
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
})
|
||||
)
|
||||
.pipe(gulp.dest(dir));
|
||||
|
||||
const assets = gulp
|
||||
.src(['src/**/*.@(png|svg)'])
|
||||
.pipe(gulp.dest(dir));
|
||||
|
||||
const sass = gulp
|
||||
.src(['src/**/*.scss'])
|
||||
.pipe(compileSass({ includePaths: ['./node_modules'], quietDeps: true }).on('error', compileSass.logError))
|
||||
.pipe(gulp.dest(dir));
|
||||
|
||||
const error = 0;
|
||||
const source = ['src/**/*.tsx', 'src/**/*.ts', '!src/**/*.test.*'];
|
||||
|
||||
const tsProject = ts.createProject('tsconfig.json')
|
||||
|
||||
const tsResult = gulp.src(source).pipe(
|
||||
tsProject()
|
||||
);
|
||||
function check() {
|
||||
if (error && !argv['ignore-error']) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
tsResult.on('finish', check);
|
||||
tsResult.on('end', check);
|
||||
const tsFilesStream = babelify(tsResult.js);
|
||||
const tsd = tsResult.dts.pipe(gulp.dest(dir));
|
||||
return merge2([less, tsFilesStream, tsd, assets, sass]);
|
||||
}
|
||||
|
||||
function babelify(js) {
|
||||
delete babelConfig.cacheDirectory;
|
||||
|
||||
const stream = js
|
||||
.pipe(sourcemaps.init())
|
||||
.pipe(babel(babelConfig))
|
||||
.pipe(sourcemaps.write('.'));
|
||||
|
||||
return stream.pipe(gulp.dest(dir));
|
||||
}
|
||||
|
||||
function tsDocGen(cb) {
|
||||
const apiExtractorJsonPath = path.join(cwd, 'api-extractor.json');
|
||||
|
||||
// Load and parse the api-extractor.json file
|
||||
const extractorConfig = apiExtractor.ExtractorConfig.loadFileAndPrepare(apiExtractorJsonPath);
|
||||
|
||||
// Invoke API Extractor
|
||||
const extractorResult = apiExtractor.Extractor.invoke(extractorConfig, {
|
||||
// Equivalent to the "--local" command-line parameter
|
||||
localBuild: true,
|
||||
|
||||
// Equivalent to the "--verbose" command-line parameter
|
||||
showVerboseMessages: true
|
||||
});
|
||||
|
||||
if (extractorResult.succeeded) {
|
||||
console.error(`API Extractor completed successfully`);
|
||||
} else {
|
||||
console.error(`API Extractor completed with ${extractorResult.errorCount} errors`
|
||||
+ ` and ${extractorResult.warningCount} warnings`);
|
||||
}
|
||||
|
||||
cb();
|
||||
}
|
||||
|
||||
function buildAndWatch(cb) {
|
||||
watch(['src/**/*.tsx', 'src/**/*.ts', 'src/**/*.scss', 'src/**/*.less'], { ignoreInitial: false },
|
||||
series(compile, autoprefixCss, typedCss, parallel(combineComponentsCss, combineBaseCss, tsDocGen)));
|
||||
cb();
|
||||
}
|
||||
|
||||
module.exports.default = series(compile, autoprefixCss, typedCss, combineComponentsCss, combineBaseCss, tsDocGen);
|
||||
module.exports.watch = buildAndWatch;
|
||||
module.exports.tsDocGen = tsDocGen;
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest/presets/js-with-babel',
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
babelConfig: true,
|
||||
}
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js']
|
||||
};
|
||||
Generated
+19228
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@strata/tempo",
|
||||
"version": "4.0.0",
|
||||
"description": "React Components that are part of the Tempo Design System.",
|
||||
"main": "lib/index.js",
|
||||
"scripts": {
|
||||
"test": "jest --passWithNoTests",
|
||||
"test:cover": "jest --coverage",
|
||||
"wbp": "gulp --no-experimental-fetch",
|
||||
"wbp-w": "gulp watch",
|
||||
"update-api": "api-extractor run --local",
|
||||
"install-peers": "install-peerdeps react-router-dom@4.3.1"
|
||||
},
|
||||
"author": "Strata",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.1.4",
|
||||
"@strata/intl": "^4.1.0",
|
||||
"@strata/logging": "0.0.3",
|
||||
"@strata/styles": "^3.2.0",
|
||||
"@types/url-parse": "^1.4.3",
|
||||
"antd": "^5.25.3",
|
||||
"async-validator": "^3.4.0",
|
||||
"bootstrap": "^4.5.0",
|
||||
"less": "^3.11.3",
|
||||
"lodash": "^4.17.15",
|
||||
"primereact": "6.3.2",
|
||||
"react-svg": "^11.0.28",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"url-parse": "^1.4.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0"
|
||||
},
|
||||
"files": [
|
||||
"/lib",
|
||||
"/src/"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "7.8.1",
|
||||
"@strata/babel": "^2.330.0",
|
||||
"@strata/test-utils": "^2.0.0",
|
||||
"@testing-library/jest-dom": "^5.11.6",
|
||||
"@testing-library/react": "^10.4.9",
|
||||
"@testing-library/user-event": "^12.6.0",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/react": "^16.14.2",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/react-test-renderer": "^16.9.4",
|
||||
"autoprefixer": "^9.8.4",
|
||||
"css-loader": "^3.6.0",
|
||||
"gulp": "^4.0.2",
|
||||
"gulp-babel": "^8.0.0",
|
||||
"gulp-clean-css": "^4.3.0",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-postcss": "^8.0.0",
|
||||
"gulp-sass": "^5.1.0",
|
||||
"gulp-sourcemaps": "^2.6.5",
|
||||
"gulp-typed-css-modules": "^2.0.1",
|
||||
"gulp-typescript": "^6.0.0-alpha.1",
|
||||
"jest": "^26.6.3",
|
||||
"less-loader": "^6.1.3",
|
||||
"less-plugin-npm-import": "^2.1.0",
|
||||
"merge2": "^1.4.1",
|
||||
"react": "^16.13.1",
|
||||
"react-docgen": "^5.3.0",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-test-renderer": "16.13.1",
|
||||
"rucksack-css": "^1.0.2",
|
||||
"sass": "^1.62.1",
|
||||
"ts-jest": "^26.4.4",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://proget.sdt.local/npm/npm/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import Accordion from './Accordion';
|
||||
import Text from '../text/Text';
|
||||
|
||||
|
||||
test('verify panel labels are shown', () => {
|
||||
render(<>
|
||||
<Accordion items={[
|
||||
{ key: 'item-1', label: 'Item 1', children: <Text>Hidden</Text> },
|
||||
{ key: "item-2", label: "Item 2", children: <Text>Hidden</Text> }]} />
|
||||
</>);
|
||||
|
||||
// verify panel labels are rendered
|
||||
screen.getByText("Item 1");
|
||||
screen.getByText("Item 2");
|
||||
|
||||
// verify content not visible when no panels are expanded
|
||||
const contents = screen.queryAllByText("Hidden");
|
||||
expect(contents.length).toBe(0);
|
||||
|
||||
});
|
||||
|
||||
|
||||
test('verify active panel is shown', () => {
|
||||
render(<>
|
||||
<Accordion activeKey={'item-1'} items={[
|
||||
{ key: 'item-1', label: 'Item 1', children: <Text>Hidden 1</Text> },
|
||||
{ key: "item-2", label: "Item 2", children: <Text>Hidden 2</Text> }]} />
|
||||
</>);
|
||||
|
||||
// verify panels labels are rendered
|
||||
screen.getByText("Item 1");
|
||||
screen.getByText("Item 2");
|
||||
|
||||
// verify active panel is visible
|
||||
screen.getByText("Hidden 1");
|
||||
|
||||
// verify inactive panel is hidden
|
||||
const contentTwo = screen.queryAllByText("Hidden 2");
|
||||
expect(contentTwo.length).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
test('verify only one panel is shown in accordion style', () => {
|
||||
render(<>
|
||||
<Accordion items={[
|
||||
{ key: 'item-1', label: 'Item 1', children: <Text>Hidden 1</Text> },
|
||||
{ key: "item-2", label: "Item 2", children: <Text>Hidden 2</Text> }]} />
|
||||
</>);
|
||||
|
||||
// verify panels are expanded when you click on them
|
||||
fireEvent.click(screen.getByText("Item 1"));
|
||||
screen.getByText("Hidden 1");
|
||||
|
||||
// verify only one panel is visible
|
||||
fireEvent.click(screen.getByText("Item 2"));
|
||||
|
||||
expect(screen.getByText("Hidden 1")).not.toBeVisible();
|
||||
screen.getByText("Hidden 2");
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as React from "react";
|
||||
import { Collapse } from "antd";
|
||||
import Text from "../text/Text";
|
||||
import IIconProps from "../icon/IIconProps";
|
||||
import Spacing from "../spacing/Spacing";
|
||||
import DownIcon from "../icon/DownIcon";
|
||||
import RightIcon from "../icon/RightIcon";
|
||||
import { ItemType } from "rc-collapse/lib/interface";
|
||||
|
||||
export interface IAccordionProps {
|
||||
/** Keys of the active panel in a controlled state */
|
||||
activeKey?: React.Key | React.Key[];
|
||||
|
||||
/** Key of the default active panel in non controlled state */
|
||||
defaultActiveKey?: React.Key | React.Key[];
|
||||
|
||||
/** Destroy Inactive Panel */
|
||||
destroyInactivePanel?: boolean;
|
||||
|
||||
/** Called on active panel change */
|
||||
onChange?: (key: string | string[]) => void;
|
||||
|
||||
/** Accordion panels */
|
||||
items?: IAccordionItem[];
|
||||
|
||||
/**Default is set to true which only has one panel expanded at a time. */
|
||||
collapse?: boolean;
|
||||
}
|
||||
|
||||
export interface IAccordionItem {
|
||||
/** React key for the item */
|
||||
key: React.Key;
|
||||
|
||||
/** Title of the item */
|
||||
label: React.ReactNode;
|
||||
|
||||
/** Icon of the title */
|
||||
icon?: React.ReactElement<IIconProps>;
|
||||
|
||||
/** Specify whether the panel be collapsible or the trigger area of collapsible */
|
||||
collapsible?: 'header' | 'icon' | 'disabled';
|
||||
|
||||
/** Forced render of content on panel, instead of lazy rendering after clicking on header */
|
||||
forceRender?: boolean;
|
||||
|
||||
/** If false, panel will not show arrow icon. If false, collapsible can't be set as icon */
|
||||
showArrow?: boolean;
|
||||
|
||||
/** Remove body padding */
|
||||
removeBodyPadding?: boolean;
|
||||
|
||||
/** Children content */
|
||||
children?: React.ReactNode;
|
||||
|
||||
/** The extra element in the corner */
|
||||
extra?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accordion expands and collapses content
|
||||
* @beta
|
||||
*/
|
||||
const Accordion: React.FC<IAccordionProps> = (props) => {
|
||||
const { items = [], collapse = true, ...validProps } = props;
|
||||
|
||||
const validItems = items.map(item => {
|
||||
let title = item.label;
|
||||
if (item.icon) {
|
||||
title = <Spacing vAlign="center" itemSpacing={12}>{item.icon}<span>{item.label}</span></Spacing>
|
||||
}
|
||||
|
||||
const newItem = {
|
||||
key: item.key,
|
||||
label: <Text.Heading level={3}>{title}</Text.Heading>,
|
||||
collapsible: item.collapsible,
|
||||
forceRender: item.forceRender,
|
||||
showArrow: item.showArrow,
|
||||
className: item.removeBodyPadding === true ? 'tempo-accordion-item tempo-accordion-item--no-padding' : 'tempo-accordion-item',
|
||||
children: item.children,
|
||||
extra: item.extra
|
||||
} as ItemType;
|
||||
return newItem;
|
||||
});
|
||||
|
||||
return (
|
||||
<Collapse {...validProps}
|
||||
items={validItems}
|
||||
accordion={collapse}
|
||||
expandIconPosition="end"
|
||||
expandIcon={({ isActive }) => isActive ? <DownIcon color="blue-500" size={14} /> : <RightIcon color="blue-500" size={14} />}
|
||||
className='tempo-accordion' />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default Accordion;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Accordion, { IAccordionProps, IAccordionItem } from "./Accordion";
|
||||
|
||||
export default Accordion;
|
||||
export { IAccordionProps, IAccordionItem };
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ActionBar from './ActionBar';
|
||||
import Input from '../input/Input';
|
||||
import DropDown from '../dropdown/DropDown';
|
||||
import Button from '../button/Button';
|
||||
import { dropDownTestUtils } from '@strata/test-utils/lib';
|
||||
|
||||
|
||||
test('verify filters are shown', () => {
|
||||
render(<>
|
||||
<ActionBar filters={<><Input width={200} search />
|
||||
<DropDown width={160} defaultValue="" selectAllText="All Options"
|
||||
items={[
|
||||
{ text: "Option A", value: 1 },
|
||||
{ text: "Option B", value: 2 }
|
||||
]}
|
||||
/></>}></ActionBar>
|
||||
</>);
|
||||
|
||||
// verify input and dropdown are rendered
|
||||
screen.getByRole("textbox");
|
||||
dropDownTestUtils.getDropDown();
|
||||
|
||||
});
|
||||
|
||||
test('verify viewToggle is shown', () => {
|
||||
render(<>
|
||||
<ActionBar viewToggle={<Button>Test</Button>}></ActionBar>
|
||||
</>);
|
||||
|
||||
// verify input and dropdown are rendered
|
||||
screen.getByRole("button", { name: "Test" });
|
||||
});
|
||||
|
||||
test('verify actions are shown', () => {
|
||||
render(<>
|
||||
<ActionBar actions={<>
|
||||
<Button>Add Item</Button>
|
||||
<Button>Run Process</Button>
|
||||
</>}></ActionBar>
|
||||
</>);
|
||||
|
||||
// verify input and dropdown are rendered
|
||||
screen.getByRole("button", { name: "Add Item" });
|
||||
screen.getByRole("button", { name: "Run Process" });
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
import Spacing from "../spacing";
|
||||
import Divider from "../divider";
|
||||
|
||||
export interface IActionBarProps {
|
||||
/** Contains search, drop-downs, toggles, and other filters */
|
||||
filters?: React.ReactNode;
|
||||
|
||||
/** Contains a single view representation toggle only */
|
||||
viewToggle?: React.ReactNode;
|
||||
|
||||
/** Contains buttons and other actions */
|
||||
actions?: React.ReactNode;
|
||||
|
||||
/** padding for the side of the action bar */
|
||||
paddingLeftRight?: 0 | 8 | 12 | 16 | 24 | 32 | 40 | 48;
|
||||
|
||||
/** Additional styles. Use sparingly */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* Action Bar contains actions, search, filters and toggles for the page or section
|
||||
* @beta
|
||||
*/
|
||||
const ActionBar: React.FC<IActionBarProps> = (props: IActionBarProps) => {
|
||||
const { filters, viewToggle, actions, paddingLeftRight, style = {} } = props;
|
||||
let leftContent, rightContent;
|
||||
|
||||
if (filters || actions) {
|
||||
leftContent = (
|
||||
<div className="tempo-action-bar__left-content">
|
||||
{filters ? (
|
||||
<div className="tempo-action-bar__filters">
|
||||
<Spacing itemSpacing={12}>{filters}</Spacing>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tempo-action-bar__actions">
|
||||
<Spacing height={32} vAlign="center" itemSpacing={8}>{actions}</Spacing>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewToggle || (filters && actions)) {
|
||||
rightContent = (
|
||||
<div className="tempo-action-bar__right-content">
|
||||
{viewToggle}
|
||||
|
||||
{(viewToggle && (filters && actions)) && (
|
||||
<Divider vertical />
|
||||
)}
|
||||
|
||||
{(filters && actions) && (
|
||||
<div className="tempo-action-bar__actions">
|
||||
<Spacing height={32} vAlign="center" itemSpacing={8}>{actions}</Spacing>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (paddingLeftRight != null) {
|
||||
style.paddingLeft = paddingLeftRight;
|
||||
style.paddingRight = paddingLeftRight;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style} className="tempo-action-bar">
|
||||
{leftContent ? leftContent : <div></div>}
|
||||
{rightContent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ActionBar;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ActionBar, { IActionBarProps } from "./ActionBar";
|
||||
|
||||
export default ActionBar;
|
||||
export { IActionBarProps };
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { render, screen, } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import Banner from './Banner';
|
||||
import WarningIcon from '../icon/WarningIcon';
|
||||
import Text from '../text';
|
||||
|
||||
test('banner is visible with text', () => {
|
||||
render(<>
|
||||
<Banner>This is a banner</Banner>
|
||||
</>);
|
||||
|
||||
expect(screen.getByText('This is a banner')).toBeDefined();
|
||||
});
|
||||
|
||||
test('banner is visible with warning icon', () => {
|
||||
render(<>
|
||||
<Banner icon={<WarningIcon />}>This is a banner</Banner>
|
||||
</>);
|
||||
|
||||
const menuItem = screen.getByRole("img");
|
||||
expect(menuItem).toHaveClass('anticon-warning');
|
||||
});
|
||||
|
||||
test('banner extra is visible when size is not small', () => {
|
||||
render(<>
|
||||
<Banner extra={<Text>Test Extra</Text>}>This is a banner</Banner>
|
||||
</>);
|
||||
|
||||
expect(screen.getByText('Test Extra')).toBeDefined();
|
||||
});
|
||||
|
||||
test('banner extra is not visible when size is small', () => {
|
||||
render(<>
|
||||
<Banner extra={<Text>Test Extra</Text>} size='small'>This is a banner</Banner>
|
||||
</>);
|
||||
|
||||
expect(screen.queryByText('Test Extra')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import CheckCircleIcon from "../icon/CheckCircleIcon";
|
||||
import InfoCircleIcon from "../icon/InfoCircleIcon";
|
||||
import WarningIcon from "../icon/WarningIcon";
|
||||
import IIconProps from "../icon/IIconProps";
|
||||
import WarningCircleIcon from "../icon/WarningCircleIcon";
|
||||
|
||||
export interface IBannerProps {
|
||||
/** Banner type. Default is "info" */
|
||||
type?: "info" | "error" | "success" | "attention";
|
||||
|
||||
/** Banner size. Default is "normal" */
|
||||
size?: "normal" | "small";
|
||||
|
||||
/** Allow for a choice of icon when using an Info banner. Default is "InfoCircle" */
|
||||
icon?: React.ReactElement<IIconProps>;
|
||||
|
||||
/** Content to the right of the banner text. Cannot be used when size is small */
|
||||
extra?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banners provide conditional information, in context
|
||||
*/
|
||||
const Banner: React.FC<IBannerProps> = (props) => {
|
||||
const type = props.type || "info";
|
||||
const size = props.size || "normal";
|
||||
|
||||
let containerClassName = "tempo-banner-container";
|
||||
if (size === "small") containerClassName += " tempo-banner-container--small";
|
||||
|
||||
let className = "tempo-banner";
|
||||
if (type === "error") className += " tempo-banner--error";
|
||||
if (type === "success") className += " tempo-banner--success";
|
||||
if (type === "attention") className += " tempo-banner--attention"
|
||||
|
||||
let extraContent;
|
||||
if (props.extra && size !== "small") {
|
||||
extraContent = <div className="tempo-banner__extra">{props.extra}</div>;
|
||||
}
|
||||
|
||||
let bannerIcon = props.icon ?? <InfoCircleIcon color='blue-700' size={20} />;
|
||||
if (type == 'error') {
|
||||
bannerIcon = <WarningIcon color='error' size={20} />;
|
||||
} else if (type == 'success') {
|
||||
bannerIcon = <CheckCircleIcon color='success' size={20} />;
|
||||
} else if (type == 'attention') {
|
||||
bannerIcon = <WarningCircleIcon color='attention-700' size={20} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={className}>
|
||||
<div className="tempo-banner__content">
|
||||
{bannerIcon}
|
||||
<div className="tempo-banner__text">{props.children}</div>
|
||||
</div>
|
||||
{extraContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Banner;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Banner, { IBannerProps } from "./Banner"
|
||||
|
||||
export default Banner;
|
||||
export { IBannerProps }
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import Button from './Button';
|
||||
import WarningIcon from '../icon/WarningIcon';
|
||||
|
||||
|
||||
test('verify icon is shown', () => {
|
||||
let buttonClicked = "";
|
||||
render(<>
|
||||
<Button icon={<WarningIcon />}>One</Button>
|
||||
</>);
|
||||
|
||||
// get button by text
|
||||
screen.getByRole("button", { name: /One$/ });
|
||||
|
||||
// get icon
|
||||
screen.getByRole("img", { name: "warning" });
|
||||
|
||||
});
|
||||
|
||||
|
||||
test('verify click', () => {
|
||||
let buttonClicked = "";
|
||||
render(<>
|
||||
<Button onClick={() => buttonClicked = "One"}>One</Button>
|
||||
<Button onClick={() => buttonClicked = "Two"}>Two</Button>
|
||||
</>);
|
||||
|
||||
// get button by text
|
||||
const buttonOne = screen.getByRole("button", { name: "One" });
|
||||
fireEvent.click(buttonOne);
|
||||
|
||||
expect(buttonClicked).toEqual("One");
|
||||
});
|
||||
|
||||
test('disabled button not clickable', () => {
|
||||
let buttonClicked = "";
|
||||
render(<>
|
||||
<Button disabled onClick={() => buttonClicked = "One"}>One</Button>
|
||||
<Button onClick={() => buttonClicked = "Two"}>Two</Button>
|
||||
</>);
|
||||
|
||||
// get button by text
|
||||
const buttonOne = screen.getByRole("button", { name: "One" });
|
||||
fireEvent.click(buttonOne);
|
||||
|
||||
expect(buttonClicked).toEqual("");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from "react";
|
||||
import { Button as AntButton } from "antd";
|
||||
import { ButtonType as AntButtonType } from "antd/lib/button";
|
||||
import { logger } from '@strata/logging/lib';
|
||||
import IIconProps from "../icon/IIconProps";
|
||||
import reactNodeToString from "../utils/ReactNodeToString";
|
||||
|
||||
export const ButtonClassName = 'tempo-btn';
|
||||
|
||||
export type ButtonType = "primary" | "secondary" | "tertiary" | "link";
|
||||
|
||||
export interface IButtonProps {
|
||||
/** Use with caution */
|
||||
className?: string;
|
||||
|
||||
/** Button type */
|
||||
type?: ButtonType;
|
||||
|
||||
/** Button icon */
|
||||
icon?: React.ReactElement<IIconProps>;
|
||||
|
||||
/** HTML button type. Default is "button" */
|
||||
htmlType?: "submit" | "button";
|
||||
|
||||
/** Called on button click */
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
|
||||
/** Expand to container width */
|
||||
block?: boolean;
|
||||
|
||||
/** Disable item */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Danger style (red) */
|
||||
danger?: boolean;
|
||||
|
||||
/** Additional log data */
|
||||
logData?: object;
|
||||
|
||||
/** Turn off logging. Default to false */
|
||||
disableLogging?: boolean;
|
||||
|
||||
/** Additional styles. Use sparingly*/
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const buttonTypeMappings: {
|
||||
[key: string]: AntButtonType;
|
||||
} = {
|
||||
primary: "primary",
|
||||
secondary: "default",
|
||||
tertiary: "dashed",
|
||||
link: "link"
|
||||
};
|
||||
|
||||
/** Buttons perform actions in a single click, tap, or keypress. */
|
||||
const Button: React.FC<IButtonProps> = (props) => {
|
||||
let { type = "secondary", logData, disableLogging = false, onClick, className = "", ...validProps } = props;
|
||||
|
||||
className = className == "" ? ButtonClassName : ButtonClassName + " " + className;
|
||||
|
||||
const children = props.children;
|
||||
const buttonText = reactNodeToString(children ?? "");
|
||||
|
||||
const onClickInternal = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
!disableLogging && logger.log("button click", buttonText, logData);
|
||||
onClick && onClick(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<AntButton data-pendo-tag={buttonText} {...validProps} type={buttonTypeMappings[type]} className={className} onClick={onClickInternal}>{props.children}</AntButton>
|
||||
);
|
||||
};
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Button, { IButtonProps, ButtonType } from "./Button"
|
||||
|
||||
export default Button;
|
||||
export { IButtonProps, ButtonType }
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import ButtonMenu from './ButtonMenu';
|
||||
import PlusIcon from '../icon/PlusIcon';
|
||||
import FileIcon from '../icon/FileIcon';
|
||||
|
||||
test('button menu with click event', async () => {
|
||||
render(<>
|
||||
<ButtonMenu buttonText="Menu" icon={<PlusIcon />} items={[
|
||||
{ key: "1", label: "Menu Item 1", icon: <FileIcon /> },
|
||||
{ key: "2", label: "Menu Item 2", icon: <FileIcon /> }
|
||||
]} /></>);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
screen.getByRole("img", { name: "plus" });
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
screen.getByText('Menu Item 1');
|
||||
screen.getByText('Menu Item 2');
|
||||
});
|
||||
|
||||
test('hidden button menu items are hidden', async () => {
|
||||
render(<>
|
||||
<ButtonMenu buttonText="Menu" icon={<PlusIcon />} items={[
|
||||
{ key: "1", label: "Menu Item 1", hidden: true, icon: <FileIcon /> },
|
||||
{ key: "2", label: "Menu Item 2", icon: <FileIcon /> }
|
||||
]} /></>);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
screen.getByRole("img", { name: "plus" });
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.queryByText('Menu Item 1')).toBeNull();
|
||||
screen.getByText('Menu Item 2');
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as React from "react";
|
||||
import { Dropdown } from "antd";
|
||||
import Button, { ButtonType } from '../button/Button';
|
||||
import Tooltip from "../tooltip/Tooltip";
|
||||
import IIconProps from "../icon/IIconProps";
|
||||
import DownIcon from "../icon/DownIcon";
|
||||
import { IMenuItem, toAntItemTypes } from "../menu/Menu";
|
||||
|
||||
export interface IButtonMenuClickEvent {
|
||||
/** Clicked item key */
|
||||
key: React.Key;
|
||||
|
||||
/** CLicked item key path */
|
||||
keyPath: React.Key[];
|
||||
|
||||
/** Clicked item */
|
||||
item: React.ReactInstance;
|
||||
|
||||
/** HTML dom event */
|
||||
domEvent: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>;
|
||||
}
|
||||
|
||||
|
||||
export interface IButtonMenuProps {
|
||||
/** Disable item */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Popup menu placement. Default is "bottomLeft" */
|
||||
placement?: "topLeft" | "topCenter" | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight";
|
||||
|
||||
/** Button text */
|
||||
buttonText?: React.ReactNode;
|
||||
|
||||
/** Additional button styles. Use sparingly*/
|
||||
buttonStyle?: React.CSSProperties;
|
||||
|
||||
/** Menu items */
|
||||
items?: IMenuItem[];
|
||||
|
||||
/** Currently selected menu item keys */
|
||||
selectedKeys?: Array<string>;
|
||||
|
||||
/** Called on menu item click */
|
||||
onClick?: (e: IButtonMenuClickEvent) => void;
|
||||
|
||||
/** Called when menu is opened or closed */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
||||
/** Button type. */
|
||||
type?: ButtonType;
|
||||
|
||||
/** Button icon */
|
||||
icon?: React.ReactElement<IIconProps>;
|
||||
|
||||
/** The text shown in the tooltip */
|
||||
tooltip?: string;
|
||||
|
||||
/** The position of the tooltip relative to the target. Default is "bottom" */
|
||||
tooltipPlacement?: 'top' | 'left' | 'right';
|
||||
|
||||
/** Keep menu open on item click */
|
||||
stayOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button menu allows a user to select from a dropdown of multiple menu items
|
||||
* @beta
|
||||
*/
|
||||
const ButtonMenu: React.FC<IButtonMenuProps> = (props: IButtonMenuProps) => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const { placement = "bottomLeft", type = "secondary", stayOpen = false } = props;
|
||||
|
||||
// if text is provided, always add the dropdown icon. if no text is provided, render just props.icon
|
||||
const buttonContent = props.buttonText && (
|
||||
<span className="tempo-dropdown-button-content">
|
||||
{props.buttonText}
|
||||
<DownIcon size={14} style={{ marginLeft: '8px' }} />
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderButton = (tooltip: string | undefined) => {
|
||||
return (
|
||||
tooltip ?
|
||||
<Tooltip title={props.tooltip} placement={props.tooltipPlacement}>
|
||||
<Button icon={props.icon} type={type} disabled={props.disabled} style={props.buttonStyle}>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
</Tooltip> :
|
||||
<Button icon={props.icon} type={type} disabled={props.disabled} style={props.buttonStyle}>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const handleMenuItemClick = (e: IButtonMenuClickEvent) => {
|
||||
if (!stayOpen) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
props.onClick && props.onClick(e);
|
||||
}
|
||||
|
||||
const handleVisibleChange = (visible: boolean) => {
|
||||
props.onOpenChange && props.onOpenChange(visible);
|
||||
setOpen(visible);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown open={open} onOpenChange={handleVisibleChange} disabled={props.disabled} placement={placement}
|
||||
menu={{
|
||||
className: "tempo-button-menu-menu",
|
||||
onClick: handleMenuItemClick,
|
||||
selectedKeys: props.selectedKeys,
|
||||
items: toAntItemTypes(props.items)
|
||||
}
|
||||
}
|
||||
trigger={['click']}>
|
||||
{renderButton(props.tooltip)}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ButtonMenu;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ButtonMenu, { IButtonMenuProps, IButtonMenuClickEvent } from "./ButtonMenu"
|
||||
|
||||
export default ButtonMenu;
|
||||
export { IButtonMenuProps, IButtonMenuClickEvent }
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import Card from './Card';
|
||||
import Button from '../../lib/button/Button';
|
||||
import EditIcon from '../icon/EditIcon'
|
||||
|
||||
test('verify basic card data displays with button click', async () => {
|
||||
const title = 'This is a title for a card';
|
||||
const description = 'that title sucks';
|
||||
const body = 'this is the body';
|
||||
const buttonText = 'Click this';
|
||||
const handleExtraButtonClickMock = jest.fn();
|
||||
|
||||
render(<>
|
||||
<Card
|
||||
title={title}
|
||||
icon={<EditIcon />}
|
||||
description={description}
|
||||
extra={<Button onClick={handleExtraButtonClickMock}>{buttonText}</Button>}
|
||||
>{body}</Card>
|
||||
</>)
|
||||
|
||||
//Validate card is being displayed with data.
|
||||
await screen.findByText(title);
|
||||
await screen.findByLabelText('edit');
|
||||
await screen.findByText(description);
|
||||
await screen.findByText(body);
|
||||
|
||||
//find and kick off close button
|
||||
const button = await screen.findByRole('button', { name: buttonText });
|
||||
fireEvent.click(button);
|
||||
|
||||
//Verify close function called
|
||||
expect(handleExtraButtonClickMock).toBeCalled();
|
||||
});
|
||||
|
||||
test('verify card displays and is clicked', async () => {
|
||||
const body = 'This is a clickable body for a card';
|
||||
const handleCardClicked = jest.fn();
|
||||
|
||||
render(<>
|
||||
<Card onClick={handleCardClicked}
|
||||
>{body}</Card>
|
||||
</>);
|
||||
|
||||
//Find and click card
|
||||
const card = await screen.findByText(body);
|
||||
fireEvent.click(card);
|
||||
|
||||
//verify click function is called.
|
||||
expect(handleCardClicked).toBeCalled();
|
||||
});
|
||||
|
||||
test('verify multiple cards displays with types', async () => {
|
||||
const titleError = 'black jack';
|
||||
const titleSuccess = 'success';
|
||||
const body = 'poker';
|
||||
|
||||
render(<>
|
||||
<Card title={titleError} type='error' />
|
||||
<Card title={titleSuccess} type='success'>{body}</Card>
|
||||
</>);
|
||||
|
||||
await screen.findByText(titleError);
|
||||
await screen.findByText(titleSuccess);
|
||||
await screen.findByText(body);
|
||||
await screen.findByLabelText('warning');
|
||||
await screen.findByLabelText('check-circle');
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as React from "react";
|
||||
import { Card as AntCard } from "antd";
|
||||
import Loader from "../loader/Loader";
|
||||
import Spacing from "../spacing/Spacing";
|
||||
import Text from '../text/Text';
|
||||
import Image from '../image/Image';
|
||||
import Link from '../link/Link';
|
||||
import CheckCircleIcon from "../icon/CheckCircleIcon";
|
||||
import WarningIcon from "../icon/WarningIcon";
|
||||
import IIconProps from "../icon/IIconProps";
|
||||
import WarningCircleIcon from "../icon/WarningCircleIcon";
|
||||
|
||||
export interface ICardProps {
|
||||
/** Card title */
|
||||
title?: React.ReactNode;
|
||||
|
||||
/** Title icon */
|
||||
icon?: React.ReactElement<IIconProps>;
|
||||
|
||||
/** Card type. Default is "normal." "success", "attention", and "error" override the icon prop */
|
||||
type?: "normal" | "success" | "error" | "attention";
|
||||
|
||||
/** Card description below the title */
|
||||
description?: string;
|
||||
|
||||
/** Content to the right of the card title */
|
||||
extra?: React.ReactNode;
|
||||
|
||||
/** Cover image src. Cannot be used with title */
|
||||
image?: string;
|
||||
|
||||
/** Card footer. Requires children to be set */
|
||||
footer?: React.ReactNode;
|
||||
|
||||
/** Remove body padding */
|
||||
removeBodyPadding?: boolean;
|
||||
|
||||
/** Min height of the card, excluding the height of the title or image */
|
||||
height?: number | string;
|
||||
|
||||
/** Trigger an action on card click. Cannot be used with href */
|
||||
onClick?: () => void;
|
||||
|
||||
/** Trigger a link on card click. Cannot be used with onClick */
|
||||
href?: string;
|
||||
|
||||
/** Show loading mask */
|
||||
loading?: boolean;
|
||||
|
||||
/** Add selected style */
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
/** Cards structure content like text, forms, and tables. */
|
||||
const Card: React.FC<ICardProps> = (props) => {
|
||||
const { removeBodyPadding, loading, selected, type, image, description, height, onClick, href, ...validProps } = props;
|
||||
|
||||
let className = "tempo-card";
|
||||
if (removeBodyPadding || !validProps.children) {
|
||||
className += " tempo-card--body-no-padding";
|
||||
}
|
||||
if (selected) {
|
||||
className += " tempo-card--selected"
|
||||
}
|
||||
|
||||
let title = validProps.title;
|
||||
if (title) {
|
||||
title = <Text.Heading level={3}>{title}</Text.Heading>;
|
||||
|
||||
if (description) {
|
||||
title = (
|
||||
<Spacing column padding={"12px 0"}>
|
||||
{title}
|
||||
<Text color="secondary">{description}</Text>
|
||||
</Spacing>
|
||||
);
|
||||
}
|
||||
|
||||
let icon: React.ReactElement<IIconProps> | null = null;
|
||||
|
||||
if (props.icon) {
|
||||
icon = React.cloneElement(props.icon, {
|
||||
size: props.icon.props.size || 20,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "success") {
|
||||
icon = <CheckCircleIcon color='success' size={20} />
|
||||
} else if (type === "error") {
|
||||
icon = <WarningIcon color='error' size={20} />;
|
||||
}
|
||||
else if (type === "attention") {
|
||||
icon = <WarningCircleIcon color='attention' size={20} />;
|
||||
}
|
||||
|
||||
if (icon != null) {
|
||||
title = (
|
||||
<Spacing vAlign="center" itemSpacing={8}>
|
||||
{icon}
|
||||
{title}
|
||||
</Spacing>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let extra = validProps.extra;
|
||||
|
||||
let cover;
|
||||
if (!title && image) {
|
||||
cover = <Image src={image} height={180} border="bottom" backgroundColor="#F5F5F5" />;
|
||||
}
|
||||
|
||||
let contents = validProps.children;
|
||||
if (contents) {
|
||||
contents = <div style={{ minHeight: height }}>{contents}</div>;
|
||||
|
||||
if (validProps.footer) {
|
||||
contents = (
|
||||
<div className="tempo-card-contents-and-footer" style={{ minHeight: height }}>
|
||||
<div>{validProps.children}</div>
|
||||
<div className="tempo-card-footer">{validProps.footer}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tabIndex, onKeyPress;
|
||||
if (onClick && !loading) {
|
||||
tabIndex = 0;
|
||||
|
||||
onKeyPress = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.key === 'Enter' && onClick();
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(validProps, {
|
||||
hoverable: props.onClick || href
|
||||
});
|
||||
|
||||
const card = <AntCard {...validProps}
|
||||
onClick={onClick}
|
||||
onKeyPress={onKeyPress}
|
||||
tabIndex={tabIndex}
|
||||
title={title}
|
||||
extra={extra}
|
||||
cover={cover}
|
||||
className={className}>{contents}</AntCard>;
|
||||
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
{href ? <Link href={href}>{card}</Link> : card}
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Card, { ICardProps } from "./Card"
|
||||
|
||||
export default Card;
|
||||
export { ICardProps }
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as React from "react";
|
||||
import { Cascader as AntCascader } from "antd";
|
||||
import { DefaultOptionType } from "rc-cascader/lib/";
|
||||
|
||||
export interface ICascaderProps {
|
||||
/** Show clear button. Defaults to true */
|
||||
allowClear?: boolean;
|
||||
|
||||
/** If get focus when component mounted. Defaults to false */
|
||||
autoFocus?: boolean;
|
||||
|
||||
/** Change value on each selection if set to true. Defaults to false */
|
||||
changeOnSelect?: boolean;
|
||||
|
||||
/** Initial selected value */
|
||||
defaultValue?: (string | number)[];
|
||||
|
||||
/** Whether disabled select */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Set visiblity of cascader popup */
|
||||
open?: boolean;
|
||||
|
||||
/** The data options of cascade */
|
||||
options?: ICascaderOption[];
|
||||
|
||||
/** The input placeholder */
|
||||
placeholder?: string;
|
||||
|
||||
/** The position of the cascader relative to the target. Default is "bottomLeft" */
|
||||
placement?: "bottomLeft" | "bottomRight" | "topLeft" | "topRight"
|
||||
|
||||
/** Show search */
|
||||
showSearch?: boolean;
|
||||
|
||||
/** Set validation status */
|
||||
status?: 'error';
|
||||
|
||||
/** The selected value*/
|
||||
value?: string[] | number[];
|
||||
|
||||
/** Called when finishing cascader select */
|
||||
onChange?: (value: (string | number | null)[], selectOptions: ICascaderOption[]) => void;
|
||||
|
||||
/** Called when cascader shown or hidden */
|
||||
onOpenChange?: (visible: boolean) => void;
|
||||
|
||||
/** Called on search value change */
|
||||
onSearch?: (value: string) => void;
|
||||
}
|
||||
|
||||
export interface ICascaderOption {
|
||||
/** Item key */
|
||||
value?: string | number | null;
|
||||
/** Item label */
|
||||
label?: React.ReactNode;
|
||||
/** Whether disabled select */
|
||||
disabled?: boolean;
|
||||
/** ICascaderOption data array (value should be unique across the whole array) */
|
||||
children?: ICascaderOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascader allows users to select an option from a hierarchical list and displays the full selection path.
|
||||
* @beta
|
||||
*/
|
||||
const Cascader: React.FC<ICascaderProps> = (props:ICascaderProps) => {
|
||||
const { options = [], ...validProps } = props;
|
||||
|
||||
const validOptions = options.map(option => {
|
||||
const newItem = {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
disabled: option.disabled,
|
||||
children: option.children
|
||||
} as DefaultOptionType;
|
||||
return newItem;
|
||||
});
|
||||
|
||||
return (
|
||||
<AntCascader options={validOptions} multiple={false} className='tempo-cascader' {...validProps} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default Cascader;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Cascader, { ICascaderProps, ICascaderOption } from "./Cascader";
|
||||
|
||||
export default Cascader;
|
||||
export { ICascaderProps, ICascaderOption };
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { render, screen,} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import '@testing-library/jest-dom';
|
||||
import Checkbox from './Checkbox';
|
||||
|
||||
test('verify check', () => {
|
||||
const handleChange = () => {console.log("You clicked!")};
|
||||
render(<>
|
||||
<Checkbox onChange={handleChange}>One</Checkbox>
|
||||
</>);
|
||||
|
||||
// get checkbox by text
|
||||
const checkboxOne = screen.getByRole("checkbox", { name: "One" });
|
||||
userEvent.click(checkboxOne);
|
||||
|
||||
expect(checkboxOne).toBeChecked();
|
||||
});
|
||||
|
||||
test('disabled checkbox not clickable', () => {
|
||||
const handleChange = () => {console.log("You clicked!")};
|
||||
render(<>
|
||||
<Checkbox disabled onChange={handleChange}>One</Checkbox>
|
||||
</>);
|
||||
|
||||
// get checkbox by text
|
||||
const checkboxOne = screen.getByRole("checkbox", { name: "One" });
|
||||
userEvent.click(checkboxOne);
|
||||
|
||||
expect(checkboxOne).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('checked checkbox becomes unchecked', () => {
|
||||
const handleChange = () => {console.log("You clicked!")};
|
||||
render(<>
|
||||
<Checkbox defaultChecked onChange={handleChange}>One</Checkbox>
|
||||
</>);
|
||||
|
||||
// get checkbox by text
|
||||
const checkboxOne = screen.getByRole("checkbox", { name: "One" });
|
||||
//make sure the checkbox is checked by default
|
||||
expect(checkboxOne).toBeChecked();
|
||||
//now uncheck it
|
||||
userEvent.click(checkboxOne);
|
||||
|
||||
expect(checkboxOne).not.toBeChecked();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { Checkbox as AntCheckbox } from "antd";
|
||||
import { CheckboxChangeEvent } from "antd/lib/checkbox/Checkbox";
|
||||
import { logger } from "@strata/logging/lib/logger";
|
||||
|
||||
export { CheckboxChangeEvent } from "antd/lib/checkbox/Checkbox";
|
||||
|
||||
export interface ICheckboxProps {
|
||||
/** Default the checkbox to selected */
|
||||
defaultChecked?: boolean;
|
||||
|
||||
/** Is the checkbox selected */
|
||||
checked?: boolean;
|
||||
|
||||
/** Whether checkbox is in indeterminate state (tri-state mode) */
|
||||
indeterminate?: boolean;
|
||||
|
||||
/** Disable item */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Get focus on load */
|
||||
autoFocus?: boolean;
|
||||
|
||||
/** Called on value change */
|
||||
onChange?: (e: CheckboxChangeEvent) => void;
|
||||
|
||||
/** Input name */
|
||||
name?: string;
|
||||
|
||||
/** HTML input ID */
|
||||
id?: string;
|
||||
|
||||
/** Additional log data */
|
||||
logData?: object;
|
||||
|
||||
/** Turn off logging. Default to false */
|
||||
disableLogging?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkboxes allows users to select a single option.
|
||||
*/
|
||||
const Checkbox: React.FC<ICheckboxProps> = (props) => {
|
||||
const { onChange, disableLogging = false, logData, ...validProps } = props;
|
||||
|
||||
const onChangeInternal = (e: CheckboxChangeEvent) => {
|
||||
!disableLogging && logger.log("checkbox click", props.name ?? props.id ?? "", logData);
|
||||
onChange && onChange(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<AntCheckbox {...validProps} onChange={onChangeInternal}>{props.children}</AntCheckbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default Checkbox;
|
||||
@@ -0,0 +1,4 @@
|
||||
import CheckBox, { ICheckboxProps, CheckboxChangeEvent } from "./Checkbox"
|
||||
|
||||
export default CheckBox;
|
||||
export { ICheckboxProps, CheckboxChangeEvent };
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { render, screen,} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import '@testing-library/jest-dom';
|
||||
import CheckboxGroup from './CheckboxGroup';
|
||||
|
||||
test('verify check', () => {
|
||||
const handleChange = (item: any) => {console.log("You picked: " + item)};
|
||||
render(<>
|
||||
<CheckboxGroup
|
||||
options={[
|
||||
{value:'1', label:'Option 1'},
|
||||
{value:'2', label:'Option 2'},
|
||||
{value:'3', label:'Option 3'},
|
||||
{value:'4', label:'Option 4'}
|
||||
]}
|
||||
onChange={(item) => handleChange(item)}
|
||||
/>
|
||||
|
||||
</>);
|
||||
|
||||
// get checkbox by text
|
||||
const checkboxTwo = screen.getByRole("checkbox", { name: "Option 2" });
|
||||
userEvent.click(checkboxTwo);
|
||||
|
||||
expect(checkboxTwo).toBeChecked();
|
||||
});
|
||||
|
||||
test('verify checking multiple', () => {
|
||||
const handleChange = (item: any) => {console.log("You picked: " + item)};
|
||||
render(<>
|
||||
<CheckboxGroup
|
||||
options={[
|
||||
{value:'1', label:'Option 1'},
|
||||
{value:'2', label:'Option 2'},
|
||||
{value:'3', label:'Option 3'},
|
||||
{value:'4', label:'Option 4'}
|
||||
]}
|
||||
onChange={(item) => handleChange(item)}
|
||||
/>
|
||||
|
||||
</>);
|
||||
|
||||
// get checkbox by text
|
||||
const checkboxTwo = screen.getByRole("checkbox", { name: "Option 2" });
|
||||
userEvent.click(checkboxTwo);
|
||||
const checkboxFour = screen.getByRole("checkbox", { name: "Option 4" });
|
||||
userEvent.click(checkboxFour);
|
||||
|
||||
expect(checkboxTwo).toBeChecked();
|
||||
expect(checkboxFour).toBeChecked();
|
||||
});
|
||||
|
||||
test('disabled checkbox not clickable', () => {
|
||||
const handleChange = (item: any) => {console.log("You picked: " + item)};
|
||||
render(<>
|
||||
<CheckboxGroup
|
||||
options={[
|
||||
{value:'1', label:'Option 1'},
|
||||
{value:'2', label:'Option 2'},
|
||||
{value:'3', label:'Option 3', disabled:true},
|
||||
{value:'4', label:'Option 4'}
|
||||
]}
|
||||
onChange={(item) => handleChange(item)}
|
||||
/>
|
||||
</>);
|
||||
|
||||
// get checkbox by text
|
||||
const checkboxThree = screen.getByRole("checkbox", { name: "Option 3" });
|
||||
userEvent.click(checkboxThree);
|
||||
|
||||
expect(checkboxThree).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('checked checkbox becomes unchecked', () => {
|
||||
const handleChange = (item: any) => {console.log("You picked: " + item)};
|
||||
render(<>
|
||||
<CheckboxGroup
|
||||
defaultValue={['1']}
|
||||
options={[
|
||||
{value:'1', label:'Option 1'},
|
||||
{value:'2', label:'Option 2'},
|
||||
{value:'3', label:'Option 3'},
|
||||
{value:'4', label:'Option 4'}
|
||||
]}
|
||||
onChange={(item) => handleChange(item)}
|
||||
/>
|
||||
</>);
|
||||
|
||||
// get checkbox by text
|
||||
const checkboxOne = screen.getByRole("checkbox", { name: "Option 1" });
|
||||
//make sure the checkbox is checked by default
|
||||
expect(checkboxOne).toBeChecked();
|
||||
//now uncheck it
|
||||
userEvent.click(checkboxOne);
|
||||
|
||||
expect(checkboxOne).not.toBeChecked();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { Checkbox } from "antd";
|
||||
import { CheckboxOptionType } from "antd/lib/checkbox/Group"
|
||||
export type CheckboxValueType = string | number | boolean;
|
||||
const { Group } = Checkbox;
|
||||
|
||||
export interface ICheckboxGroupProps {
|
||||
/** Array of checkbox option objects: {value: number | string, label: number | string, disabled?: boolean} */
|
||||
options?: Array<CheckboxOptionType | string>;
|
||||
|
||||
/** Disable all items */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Layout checkboxes vertically. Default is true */
|
||||
vertical?: boolean;
|
||||
|
||||
/** Checkbox components */
|
||||
children?: React.ReactNode;
|
||||
|
||||
/** Default checked values */
|
||||
defaultValue?: Array<CheckboxValueType>;
|
||||
|
||||
/** Currently checked values */
|
||||
value?: Array<CheckboxValueType>;
|
||||
|
||||
/** Called on value change */
|
||||
onChange?: (checkedValue: Array<CheckboxValueType>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox groups allow users to select any number of choices from a short list of options.
|
||||
*/
|
||||
const CheckboxGroup: React.FC<ICheckboxGroupProps> = (props: ICheckboxGroupProps) => {
|
||||
const { vertical, ...validProps } = props;
|
||||
const vert = vertical ?? true;
|
||||
const className = vert ? "tempo-checkbox-group--vertical" : undefined;
|
||||
return (
|
||||
<Group {...validProps} className={className}>{validProps.children}</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxGroup;
|
||||
@@ -0,0 +1,4 @@
|
||||
import CheckboxGroup, { ICheckboxGroupProps, CheckboxValueType } from "./CheckboxGroup"
|
||||
|
||||
export default CheckboxGroup;
|
||||
export { ICheckboxGroupProps, CheckboxValueType };
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from "react";
|
||||
import Button from '../button/Button';
|
||||
import Tooltip from '../tooltip';
|
||||
import CloseIcon from "../icon/CloseIcon";
|
||||
|
||||
export interface IChipProps {
|
||||
/** Id of chip. Must be unique in list of chips */
|
||||
id: React.Key;
|
||||
|
||||
/** Name of chip. Will be shown as display text */
|
||||
name: string | React.ReactNode;
|
||||
|
||||
/** Tooltip of chip */
|
||||
tooltip?: string | React.ReactNode;
|
||||
|
||||
/** State of chip, will affect chip color*/
|
||||
active?: boolean;
|
||||
|
||||
/** Disabled chip is not clickable and will have white background */
|
||||
disabled?: boolean;
|
||||
|
||||
/** CSS style of individual chip */
|
||||
style?: React.CSSProperties;
|
||||
|
||||
/** Called when a chip is clicked */
|
||||
onClick?: (chipId: React.Key) => void;
|
||||
|
||||
/** Enables delete icon and called when delete icon is clicked */
|
||||
onDelete?: (chipId: React.Key) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip provide contextualizing information, such as filter selections
|
||||
* @beta
|
||||
*/
|
||||
const Chip: React.FC<IChipProps> = (props) => {
|
||||
let { id, name, tooltip, onDelete, onClick, active = false, disabled = false, style } = props;
|
||||
|
||||
const classNames = ["tempo-chip"];
|
||||
if (onClick != null && !disabled) {
|
||||
classNames.push("tempo-chip--clickable");
|
||||
}
|
||||
if (active && !disabled) {
|
||||
classNames.push("tempo-chip--active");
|
||||
}
|
||||
if (disabled) {
|
||||
classNames.push("tempo-chip--disabled");
|
||||
}
|
||||
|
||||
const handleDelete = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onDelete && onDelete(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltip} >
|
||||
<span style={style} className={classNames.join(" ")} onClick={() => !disabled && onClick && onClick(id)} >
|
||||
{name}
|
||||
{onDelete && !disabled &&
|
||||
<Button type="link" icon={<CloseIcon size={14} />} disableLogging onClick={handleDelete}></Button>
|
||||
}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
Chip.displayName = "Chip";
|
||||
|
||||
export default Chip;
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import Chips from './Chips';
|
||||
import Chip from './Chip';
|
||||
|
||||
|
||||
test('render single chip', () => {
|
||||
render(<>
|
||||
<Chip id={123} name='hello world' />
|
||||
</>);
|
||||
|
||||
// get chip by text
|
||||
screen.getByText("hello world");
|
||||
});
|
||||
|
||||
test('delete single chip does not fire click event', () => {
|
||||
let chipClicked = "";
|
||||
let chipDeleted = "";
|
||||
|
||||
render(<>
|
||||
<Chip id={123} name='Chip' onClick={(chipId) => chipClicked = chipId.toString()} onDelete={(chipId) => chipDeleted = chipId.toString()} />
|
||||
</>);
|
||||
|
||||
const deleteButton = within(screen.getByText("Chip")).getByRole("button");
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
expect(chipClicked).toEqual("");
|
||||
expect(chipDeleted).toEqual("123");
|
||||
|
||||
fireEvent.click(screen.getByText("Chip"));
|
||||
|
||||
expect(chipClicked).toEqual("123");
|
||||
|
||||
});
|
||||
|
||||
test('render single chip in chips', () => {
|
||||
render(<>
|
||||
<Chips items={[{ id: 1, name: 'test' }]} onDelete={() => console.log('deleted')} />
|
||||
</>);
|
||||
|
||||
// get chip by text
|
||||
screen.getByText("test");
|
||||
|
||||
});
|
||||
|
||||
test('render multiple chips', () => {
|
||||
const testData = [{ id: 'entities', name: 'Entities' }, { id: 'paycodes', name: 'PayCodes' }, { id: 'jobcodes', name: 'JobCodes' }]
|
||||
|
||||
render(<>
|
||||
<Chips items={testData.map((td) => { return { id: td.id, name: td.name } })} onDelete={() => console.log('deleted')} />
|
||||
</>);
|
||||
|
||||
// get each chip by text
|
||||
screen.getByText("Entities");
|
||||
|
||||
screen.getByText("PayCodes");
|
||||
|
||||
screen.getByText("JobCodes");
|
||||
|
||||
});
|
||||
|
||||
test('render multiple chips and delete one', () => {
|
||||
const testData = [{ id: 'entities', name: 'Entities' }, { id: 'paycodes', name: 'PayCodes' }, { id: 'jobcodes', name: 'JobCodes' }]
|
||||
let buttonClicked = "";
|
||||
|
||||
render(<>
|
||||
<Chips items={testData.map((td) => { return { id: td.id, name: td.name } })} onDelete={(chipId) => buttonClicked = chipId.name} />
|
||||
</>);
|
||||
|
||||
// get each chip by text
|
||||
screen.getByText("Entities");
|
||||
|
||||
screen.getByText("PayCodes");
|
||||
|
||||
screen.getByText("JobCodes");
|
||||
|
||||
const entitiesChipButton = within(screen.getByText("Entities")).getByRole("button");
|
||||
|
||||
fireEvent.click(entitiesChipButton);
|
||||
|
||||
expect(buttonClicked).toEqual("Entities");
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { logger } from '@strata/logging/lib';
|
||||
import Chip from "./Chip";
|
||||
|
||||
export interface IChip {
|
||||
/** Id of chip. Must be unique in list of chips */
|
||||
id: React.Key;
|
||||
|
||||
/** Name of chip. Will be shown as display text */
|
||||
name: string;
|
||||
|
||||
/** Tooltip of chip */
|
||||
tooltip?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
export interface IChipsProps {
|
||||
/** List of chips */
|
||||
items?: IChip[];
|
||||
|
||||
/** Enables delete icon on chips and called when a chip delete icon is clicked */
|
||||
onDelete: (chip: IChip) => void;
|
||||
|
||||
/** Additional log data */
|
||||
logData?: object;
|
||||
|
||||
/** Turn off logging. Default to false */
|
||||
disableLogging?: boolean;
|
||||
|
||||
/** Children, will override items */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chips provide contextualizing information, such as filter selections
|
||||
* @beta
|
||||
*/
|
||||
const Chips: React.FC<IChipsProps> = (props) => {
|
||||
let { items, children, logData, disableLogging = false, onDelete } = props;
|
||||
|
||||
const handleDelete = (chipId: React.Key) => {
|
||||
if (items == null) { return; }
|
||||
|
||||
const chip = items.find(c => c.id === chipId);
|
||||
if (chip == null) { return; }
|
||||
|
||||
!disableLogging && logger.log("chip delete", `${chip.name} - ${chip.id}`, logData);
|
||||
onDelete && onDelete(chip);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tempo-chips">
|
||||
{items && items.map((chip: IChip, index: number) => <Chip key={`chip-${chip.id}-${index}`} {...chip} onDelete={onDelete != null ? handleDelete : undefined}></Chip>)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Chips.displayName = "Chips";
|
||||
|
||||
export default Chips;
|
||||
@@ -0,0 +1,5 @@
|
||||
import Chips, { IChipsProps, IChip } from "./Chips"
|
||||
import Chip, { IChipProps } from "./Chip";
|
||||
|
||||
export default Chips;
|
||||
export { IChipsProps, IChip, Chip, IChipProps };
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ColorPicker from './ColorPicker';
|
||||
|
||||
|
||||
test('verify onChange is fired', () => {
|
||||
let hex = '';
|
||||
|
||||
const { container } = render(
|
||||
<ColorPicker defaultValue="#005f7a" onChange={(value) => hex = value} />
|
||||
);
|
||||
|
||||
const colorPicker = container.querySelector('.ant-color-picker-color-block');
|
||||
if (colorPicker == null) {
|
||||
throw ('color picker not found');
|
||||
}
|
||||
|
||||
fireEvent.click(colorPicker);
|
||||
const hexInput = screen.getByDisplayValue('005f7a');
|
||||
fireEvent.change(hexInput, { target: { value: 'd2d755' } });
|
||||
|
||||
expect(hex).toEqual("#d2d755");
|
||||
});
|
||||
|
||||
test('presets are shown', () => {
|
||||
let hex = '';
|
||||
|
||||
const presetColors = [{
|
||||
label: 'Tempo Blues',
|
||||
colors: ['#EFFAFD', '#C3E5EC', '#77C5D5', '#007C9F', '#005F7A', '#003848']
|
||||
}, {
|
||||
label: 'Tempo Yellows',
|
||||
colors: ['#FAFBEE', '#F6F7DD', '#F1F3CC', '#D2D755']
|
||||
}];
|
||||
|
||||
const { container } = render(
|
||||
<ColorPicker defaultValue="#005f7a" presets={presetColors} />
|
||||
);
|
||||
|
||||
const colorPicker = container.querySelector('.ant-color-picker-color-block');
|
||||
if (colorPicker == null) {
|
||||
throw ('color picker not found');
|
||||
}
|
||||
|
||||
fireEvent.click(colorPicker);
|
||||
screen.getByText('Tempo Blues');
|
||||
screen.getByText('Tempo Yellows');
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { ColorPicker as AntColorPicker } from "antd";
|
||||
|
||||
export interface IColorsPreset {
|
||||
label: React.ReactNode;
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
export interface IColorPickerProps {
|
||||
/** Hex color value in controlled state */
|
||||
value?: string;
|
||||
|
||||
/** Default hex color value in uncontrolled state */
|
||||
defaultValue?: string;
|
||||
|
||||
/** Whether the clear button appears in color picker */
|
||||
allowClear?: boolean;
|
||||
|
||||
/** Whether to show popup */
|
||||
open?: boolean;
|
||||
|
||||
/** Whether picker is disabled */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Action to show popup. Defaults to click */
|
||||
trigger?: 'click' | 'hover';
|
||||
|
||||
/** Placement of popup. Defaults to bottomLeft */
|
||||
placement?: 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight';
|
||||
|
||||
/** Preset colors */
|
||||
presets?: IColorsPreset[];
|
||||
|
||||
/** Called when popup is opened */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
||||
/** Called when color is changed */
|
||||
onChange?: (hex: string) => void;
|
||||
|
||||
/** Called when clear is clicked */
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Color picker allows users to customize a color selection
|
||||
* @beta
|
||||
*/
|
||||
const ColorPicker: React.FC<IColorPickerProps> = (props) => {
|
||||
const { onChange, ...validProps } = props;
|
||||
|
||||
return (
|
||||
<AntColorPicker
|
||||
{...validProps}
|
||||
onChange={(value, hex) => onChange && onChange(value.toHexString())}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ColorPicker, { IColorPickerProps, IColorsPreset } from "./ColorPicker";
|
||||
|
||||
export default ColorPicker;
|
||||
export { IColorPickerProps, IColorsPreset };
|
||||
@@ -0,0 +1,3 @@
|
||||
import useWindowSize, { IWindowSize } from "./useWindowSize";
|
||||
|
||||
export { useWindowSize, IWindowSize };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface IWindowSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const useWindowSize = ():IWindowSize => {
|
||||
const [windowSize, setWindowSize] = useState<IWindowSize>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
// Set size at the first client-side load
|
||||
handler();
|
||||
|
||||
window.addEventListener('resize', handler);
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => {
|
||||
window.removeEventListener('resize', handler);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
|
||||
export default useWindowSize;
|
||||
@@ -0,0 +1,67 @@
|
||||
import Tooltip from "../tooltip/Tooltip";
|
||||
import * as React from "react";
|
||||
import { MouseEventHandler } from "react";
|
||||
|
||||
export interface IDataCellProps {
|
||||
/** Make the cell editable and add a border around the value */
|
||||
editable?: boolean;
|
||||
|
||||
/** Custom cell style class */
|
||||
className?: string;
|
||||
|
||||
/** Called on cell click */
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
|
||||
/** Make the cell appear selected */
|
||||
isSelected?: boolean;
|
||||
|
||||
/** Messages to display if cell failed validation */
|
||||
validationMessages?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const DataCell: React.FC<IDataCellProps> = (props) => {
|
||||
// css classes
|
||||
const classNames = ["tempo-datacell"];
|
||||
if (props.isSelected) {
|
||||
classNames.push("tempo-datacell--selected");
|
||||
}
|
||||
if (props.editable) {
|
||||
classNames.push("tempo-datacell--editable");
|
||||
}
|
||||
if (props.onClick != null) {
|
||||
classNames.push("tempo-datacell--clickable");
|
||||
}
|
||||
if (props.validationMessages && props.validationMessages.length > 0) {
|
||||
classNames.push("tempo-datacell--invalid");
|
||||
}
|
||||
if (props.className && props.className.length > 0) {
|
||||
classNames.push(props.className);
|
||||
}
|
||||
|
||||
let validationNode = null;
|
||||
if (props.validationMessages && props.validationMessages.length > 0) {
|
||||
if (props.validationMessages.length == 1) {
|
||||
validationNode = props.validationMessages[0];
|
||||
} else {
|
||||
validationNode = (<>
|
||||
{props.validationMessages.map(item => <div>{item}</div>)}
|
||||
</>);
|
||||
}
|
||||
}
|
||||
|
||||
const cell = <div onClick={props.onClick} className={classNames.join(" ")}>{props.children}</div>;
|
||||
|
||||
if (validationNode != null) {
|
||||
return <Tooltip title={validationNode}>
|
||||
{cell}
|
||||
</Tooltip>;
|
||||
} else {
|
||||
return cell;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export default DataCell;
|
||||
@@ -0,0 +1,4 @@
|
||||
import DataCell, { IDataCellProps } from "./DataCell"
|
||||
|
||||
export default DataCell;
|
||||
export { IDataCellProps };
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react";
|
||||
import { IBaseColumnProps } from "./IBaseColumnProps";
|
||||
import ColumnUtils from "./ColumnUtils";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import { IColumn } from "./IColumn";
|
||||
import DataCell from "../datacell/DataCell";
|
||||
import { Checkbox } from "antd";
|
||||
import { CheckboxChangeEvent } from "antd/lib/checkbox/Checkbox";
|
||||
import CheckCircleIcon from "../icon/CheckCircleIcon";
|
||||
|
||||
export interface ICheckboxColumnProps extends IBaseColumnProps {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const CheckboxColumn: React.FC<ICheckboxColumnProps> & IColumn = (props: ICheckboxColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
CheckboxColumn.className = "CheckboxColumn";
|
||||
|
||||
CheckboxColumn.getColumnProps = (args: IGetColumnPropsArgs) => {
|
||||
const { props, onCellEditChange, rowDataKeyField } = args;
|
||||
let { align = "center", width, isCellEditable, editable, ...validProps } = props as ICheckboxColumnProps;
|
||||
|
||||
validProps.className = ("p-datatable-checkbox-cell " + (validProps.className || "")).trim();
|
||||
|
||||
if (validProps.body == null) {
|
||||
validProps.body = (args: any, columnInfo: any) => {
|
||||
const cellArgs = ColumnUtils.getCellArgs(rowDataKeyField, args, columnInfo || props);
|
||||
const value = cellArgs.rowData[cellArgs.field];
|
||||
let isEditable = editable;
|
||||
if (isCellEditable != null) {
|
||||
isEditable = isCellEditable(cellArgs);
|
||||
}
|
||||
if (isEditable) {
|
||||
return <Checkbox key={cellArgs.cellKey + "_checkbox"}
|
||||
checked={value === true}
|
||||
onChange={(e: CheckboxChangeEvent) => onCellEditChange(cellArgs, e.target.checked)}></Checkbox>;
|
||||
} else {
|
||||
return value === true ? <CheckCircleIcon /> : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove sorting on editable columns (at least until this is solved: https://github.com/primefaces/primereact/issues/1257)
|
||||
if (editable || isCellEditable != null) {
|
||||
validProps.sortable = false;
|
||||
}
|
||||
|
||||
// don't allow custom editor since if editable is true, a checkbox is already rendered for every row
|
||||
validProps.editor = undefined;
|
||||
|
||||
// handle filter
|
||||
if (validProps.filter) {
|
||||
validProps.filterMatchMode = validProps.filterMatchMode || "contains";
|
||||
}
|
||||
|
||||
validProps.style = Object.assign({}, validProps.style, {
|
||||
textAlign: align,
|
||||
width: width
|
||||
});
|
||||
|
||||
return validProps;
|
||||
}
|
||||
|
||||
|
||||
export default CheckboxColumn;
|
||||
@@ -0,0 +1,133 @@
|
||||
import ICellArgs from "./ICellArgs";
|
||||
import React from "react";
|
||||
import { IDataGridBaseProps } from "../datagrid";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import { IBaseColumnProps } from "./IBaseColumnProps";
|
||||
import DataCell from "../datacell/DataCell";
|
||||
|
||||
const ColumnUtils = {
|
||||
|
||||
getCellBody(columnArgs: IGetColumnPropsArgs, getCellValue: (value: any) => any): (rowData: any, column: any) => any {
|
||||
const { props, onCellClick, dataCellClassName = "", selectedCellKey, invalidCells, rowDataKeyField } = columnArgs;
|
||||
let { editable = false, isCellEditable, isCellClickable, customCellValue } = props as IBaseColumnProps;
|
||||
|
||||
const body = (args: any, columnInfo: any) => {
|
||||
const cellArgs = ColumnUtils.getCellArgs(rowDataKeyField, args, columnInfo || props);
|
||||
const isSelected = selectedCellKey != null && selectedCellKey === cellArgs.cellKey;
|
||||
const value = customCellValue ? customCellValue(cellArgs) : cellArgs.rowData[cellArgs.field];
|
||||
const invalidCell = invalidCells?.find(item => item.cellKey === cellArgs.cellKey);
|
||||
let isEditable = editable;
|
||||
if (isCellEditable != null) {
|
||||
isEditable = isCellEditable(cellArgs);
|
||||
}
|
||||
|
||||
let isClickable = isEditable; // by default editable cells are clickable
|
||||
if (isCellClickable != null) {
|
||||
isClickable = isCellClickable(cellArgs);
|
||||
}
|
||||
|
||||
const cellClickArgs = {
|
||||
...cellArgs, ...{ isEditable: isEditable, preventDefault: false }
|
||||
};
|
||||
|
||||
return <DataCell isSelected={isSelected} className={dataCellClassName} editable={isEditable}
|
||||
validationMessages={invalidCell ? invalidCell.validationMessages : undefined}
|
||||
onClick={isClickable ? () => onCellClick(cellClickArgs) : undefined}>{getCellValue(value)}</DataCell>;
|
||||
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
getCellArgs(rowDataKeyField: string, eventArgs: any, columnInfo: any = {}): ICellArgs {
|
||||
// DataGrid and TreeGrid arguments are different for the same event. This is to reconcile the difference
|
||||
// PrimeReact v6.1 wrapped previous args into args: {columnProps, originalEvent} so we have to now check for columnProps
|
||||
const args = eventArgs.columnProps ?? eventArgs;
|
||||
const row = args.rowData ?? args.node ?? args;
|
||||
const cellArgs = {
|
||||
cellKey: "",
|
||||
rowKey: "",
|
||||
row: row,
|
||||
rowData: row.data ?? row,
|
||||
field: args.field ?? columnInfo.field,
|
||||
rowIndex: args.rowIndex != null ? args.rowIndex : columnInfo.rowIndex,
|
||||
gridData: args.value ?? columnInfo.value
|
||||
}
|
||||
cellArgs.rowKey = (cellArgs.row.key ?? cellArgs.rowData[rowDataKeyField] ?? (cellArgs.rowIndex) ?? "");
|
||||
cellArgs.cellKey = cellArgs.rowKey + "_" + cellArgs.field;
|
||||
return cellArgs;
|
||||
},
|
||||
|
||||
hasRowNumberColumn(gridProps: IDataGridBaseProps): boolean {
|
||||
let hasRowNumber = false;
|
||||
React.Children.forEach(gridProps.children ?? [], (child: React.ReactElement) => {
|
||||
if (child.type && (child.type as any).className) {
|
||||
if ((child.type as any).className === "RowNumberColumn") {
|
||||
hasRowNumber = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return hasRowNumber;
|
||||
},
|
||||
|
||||
hasSelectionColumn(gridProps: IDataGridBaseProps): boolean {
|
||||
let hasSelectionColumn = false;
|
||||
React.Children.forEach(gridProps.children ?? [], (child: React.ReactElement) => {
|
||||
if (child.type && (child.type as any).className) {
|
||||
if ((child.type as any).className === "SelectionColumn") {
|
||||
hasSelectionColumn = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return hasSelectionColumn;
|
||||
},
|
||||
|
||||
getColumnPropsByColumnType<T>(columns: React.ReactElement[], className: string): T[] {
|
||||
const props: T[] = [];
|
||||
React.Children.forEach(columns ?? [], (child: React.ReactElement) => {
|
||||
if (child.type && (child.type as any).className) {
|
||||
if ((child.type as any).className === className) {
|
||||
props.push(child.props as T);
|
||||
}
|
||||
}
|
||||
});
|
||||
return props;
|
||||
},
|
||||
|
||||
hasHeaders(gridProps: IDataGridBaseProps): boolean {
|
||||
// if custom column header provided or total row provided, there is a header
|
||||
if (gridProps.columnHeaderGroup != null || gridProps.totalRowData != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check if columns has headers defined
|
||||
let hasHeaders = false;
|
||||
React.Children.forEach(gridProps.children ?? [], (child: React.ReactElement) => {
|
||||
if (child.props && child.props.header != null) {
|
||||
hasHeaders = true;
|
||||
}
|
||||
});
|
||||
return hasHeaders;
|
||||
},
|
||||
|
||||
calculateFrozenWidth(gridProps: IDataGridBaseProps): number {
|
||||
let frozenWidth = 0;
|
||||
React.Children.forEach(gridProps.children || [], (child: React.ReactElement, index) => {
|
||||
if (child == null) { return; }
|
||||
|
||||
let props = child.props;
|
||||
if (props.frozen && props.width) {
|
||||
frozenWidth += props.width;
|
||||
}
|
||||
});
|
||||
|
||||
if (frozenWidth > 0) {
|
||||
if (ColumnUtils.hasRowNumberColumn(gridProps)) {
|
||||
frozenWidth += 48; // row number column fixed width defined in CSS
|
||||
}
|
||||
}
|
||||
|
||||
return frozenWidth;
|
||||
}
|
||||
};
|
||||
|
||||
export default ColumnUtils;
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
import Input from "../input";
|
||||
import Text from "../text";
|
||||
import DataCell from "../datacell/DataCell";
|
||||
import ColumnUtils from "./ColumnUtils";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import { IColumn } from "./IColumn";
|
||||
import { IBaseColumnProps } from "./IBaseColumnProps";
|
||||
|
||||
export interface IDataColumnProps extends IBaseColumnProps {
|
||||
/** Collapse text to 2 rows if text exceeds 2 rows */
|
||||
collapseLongText?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const DataColumn: React.FC<IDataColumnProps> & IColumn = (props: IDataColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
DataColumn.className = "DataColumn";
|
||||
|
||||
DataColumn.getColumnProps = (args: IGetColumnPropsArgs) => {
|
||||
const { props, onCellEditChange, onCellValidate, rowDataKeyField, validationMode = 'single-cell' } = args;
|
||||
let { align = "left", width, editable = false, isCellEditable, validationRules, collapseLongText, ...validProps } = props as IDataColumnProps;
|
||||
|
||||
if (validProps.body == null) {
|
||||
if (collapseLongText) {
|
||||
validProps.body = ColumnUtils.getCellBody(args, (value) => <Text ellipsis={{ rows: 2, expandable: true }}>{value == null ? '' : value.toString()}</Text>);
|
||||
} else {
|
||||
validProps.body = ColumnUtils.getCellBody(args, (value) => value == null ? '' : value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// remove sorting on editable columns (at least until this is solved: https://github.com/primefaces/primereact/issues/1257)
|
||||
if (editable || isCellEditable != null) {
|
||||
validProps.sortable = false;
|
||||
}
|
||||
|
||||
// handle editors
|
||||
if (editable && validProps.editor == null) {
|
||||
validProps.editor = (args: any) => {
|
||||
const cellArgs = ColumnUtils.getCellArgs(rowDataKeyField, args);
|
||||
const value = cellArgs.rowData[cellArgs.field];
|
||||
if (isCellEditable != null) {
|
||||
if (!isCellEditable(cellArgs)) {
|
||||
return <DataCell>{value}</DataCell>;
|
||||
}
|
||||
}
|
||||
return <Input defaultValue={value}
|
||||
onChange={(e) => onCellEditChange(cellArgs, e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
if (validProps.editor) {
|
||||
(validProps as ColumnProps).editorValidatorEvent = "click";
|
||||
}
|
||||
if (validationRules && validationMode === 'single-cell') {
|
||||
(validProps as ColumnProps).editorValidator = (args: any) => {
|
||||
const cellArgs = ColumnUtils.getCellArgs(rowDataKeyField, args);
|
||||
onCellValidate(cellArgs, validationRules || []);
|
||||
// always return true since we are going to handle this internally in datagrid or treegrid
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// handle filter
|
||||
if (validProps.filter) {
|
||||
validProps.filterMatchMode = validProps.filterMatchMode || "contains";
|
||||
}
|
||||
|
||||
validProps.style = Object.assign({}, validProps.style, {
|
||||
textAlign: align,
|
||||
width: width
|
||||
});
|
||||
|
||||
return validProps;
|
||||
}
|
||||
|
||||
|
||||
export default DataColumn;
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import { IBaseColumnProps } from "./IBaseColumnProps";
|
||||
import dayjs from 'dayjs';
|
||||
import intlService, { DateTimeFormat } from "@strata/intl/lib";
|
||||
import ColumnUtils from "./ColumnUtils";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import { IColumn } from "./IColumn";
|
||||
import DataCell from "../datacell/DataCell";
|
||||
import DatePicker, { IDatePickerProps } from "../datepicker/DatePicker";
|
||||
|
||||
export interface IDateColumnProps extends IBaseColumnProps {
|
||||
/** DateTimeFormat from @strata/intl package: "date" | "dateLong" | "time" | "timeLong" | "dateTime" | "dateTimeLong". Default is "date" */
|
||||
format?: DateTimeFormat;
|
||||
|
||||
/** DatePicker input props for editable cell */
|
||||
inputProps?: IDatePickerProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const DateColumn: React.FC<IDateColumnProps> & IColumn = (props: IDateColumnProps) => {
|
||||
// Note this class is only for intellisense with . typescript
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Don't put any logic here. It will never be called.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
DateColumn.className = "DateColumn";
|
||||
|
||||
DateColumn.getColumnProps = (args: IGetColumnPropsArgs) => {
|
||||
const { props, onCellEditChange, rowDataKeyField } = args;
|
||||
let { align = "right", width, editable, isCellEditable, inputProps = {}, ...validProps } = props as IDateColumnProps;
|
||||
let format = (props as IDateColumnProps).format || "date";
|
||||
|
||||
validProps.className = ("p-datatable-datepicker-cell " + (validProps.className || "")).trim();
|
||||
|
||||
if (validProps.body == null) {
|
||||
validProps.body = ColumnUtils.getCellBody(args, (value) => intlService.formatDate(value, format));
|
||||
}
|
||||
|
||||
// remove sorting on editable columns (at least until this is solved: https://github.com/primefaces/primereact/issues/1257)
|
||||
if (editable || isCellEditable != null) {
|
||||
validProps.sortable = false;
|
||||
}
|
||||
|
||||
// handle editors
|
||||
if (editable && validProps.editor == null) {
|
||||
validProps.editor = (args: any) => {
|
||||
const cellArgs = ColumnUtils.getCellArgs(rowDataKeyField, args);
|
||||
const value = cellArgs.rowData[cellArgs.field];
|
||||
if (isCellEditable != null) {
|
||||
if (!isCellEditable(cellArgs)) {
|
||||
return <DataCell>{intlService.formatDate(value, format)}</DataCell>;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultInputProps: IDatePickerProps = {
|
||||
autoFocus: true,
|
||||
defaultValue: value ? dayjs(value) : undefined,
|
||||
allowClear: false,
|
||||
format: "L",
|
||||
onChange: (value: any) => onCellEditChange(cellArgs, value),
|
||||
onFocus: (event: React.FocusEvent<HTMLInputElement>) => event.target && event.target.select && event.target.select()
|
||||
};
|
||||
|
||||
return <DatePicker {...{ ...defaultInputProps, ...inputProps }} />;
|
||||
}
|
||||
}
|
||||
if (validProps.editor) {
|
||||
(validProps as any).editorValidatorEvent = "click";
|
||||
}
|
||||
|
||||
// handle filter
|
||||
if (validProps.filter) {
|
||||
validProps.filterMatchMode = validProps.filterMatchMode || "contains";
|
||||
}
|
||||
|
||||
validProps.style = Object.assign({}, validProps.style, {
|
||||
textAlign: align,
|
||||
width: width
|
||||
});
|
||||
|
||||
return validProps;
|
||||
}
|
||||
|
||||
|
||||
export default DateColumn;
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
import { IColumn } from "./IColumn";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
|
||||
export interface IDragDropColumnProps {
|
||||
/** Fix the column while scrolling horizontally */
|
||||
frozen?: boolean;
|
||||
}
|
||||
|
||||
export const DragDropColumnClassName = "p-datatable-drag-drop-cell";
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const DragDropColumn: React.FC<IDragDropColumnProps> & IColumn = (props: IDragDropColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
DragDropColumn.className = "DragDropColumn";
|
||||
|
||||
DragDropColumn.getColumnProps = (args: IGetColumnPropsArgs): ColumnProps => {
|
||||
|
||||
return {
|
||||
frozen: args.props.frozen,
|
||||
className: DragDropColumnClassName,
|
||||
rowReorder: true
|
||||
}
|
||||
}
|
||||
|
||||
export default DragDropColumn;
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as React from "react";
|
||||
import DataCell from "../datacell/DataCell";
|
||||
import { IBaseColumnProps } from "./IBaseColumnProps";
|
||||
import ColumnUtils from "./ColumnUtils";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import Dropdown, { IDropDownProps } from "../dropdown/DropDown";
|
||||
import { IColumn } from "./IColumn";
|
||||
import isArray from "lodash/isArray";
|
||||
|
||||
export interface IDropDownColumnProps extends IBaseColumnProps {
|
||||
/** Array of drop-down items. Uses ItemTextField and ItemValueField */
|
||||
items?: any[];
|
||||
|
||||
/** Field for an item's display text. Default is "text" */
|
||||
itemTextField?: string;
|
||||
|
||||
/** Field for an item's value. Default is "value" */
|
||||
itemValueField?: string;
|
||||
|
||||
/** Dropdown input props for editable cell */
|
||||
inputProps?: IDropDownProps
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const DropDownColumn: React.FC<IDropDownColumnProps> & IColumn = (props: IDropDownColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
DropDownColumn.className = "DropDownColumn";
|
||||
|
||||
DropDownColumn.getColumnProps = (args: IGetColumnPropsArgs) => {
|
||||
const { props, onCellEditChange, rowDataKeyField } = args;
|
||||
const { width, editable, isCellEditable, items = [], itemTextField = "text", itemValueField = "value", inputProps = {}, ...validProps } = props as IDropDownColumnProps;
|
||||
|
||||
validProps.className = ("p-datatable-dropdown-cell " + (validProps.className || "")).trim();
|
||||
|
||||
const valueRenderer = (value: any) => {
|
||||
if (inputProps.multiSelect && isArray(value)) {
|
||||
if (value.length == 0) {
|
||||
return "";
|
||||
}
|
||||
else if (value.length === 1) {
|
||||
const item = items.find(i => i[itemValueField] === value[0]);
|
||||
return item ? item[itemTextField] : "";
|
||||
} else {
|
||||
const msg = `${value.length} selected`;
|
||||
return (value.length === items.length) ? "All " + msg : msg;
|
||||
}
|
||||
} else {
|
||||
const item = items.find(i => i[itemValueField] === value);
|
||||
return item ? item[itemTextField] : "";
|
||||
}
|
||||
}
|
||||
|
||||
if (validProps.body == null) {
|
||||
validProps.body = ColumnUtils.getCellBody(args, valueRenderer);
|
||||
}
|
||||
|
||||
// remove sorting on editable columns (at least until this is solved: https://github.com/primefaces/primereact/issues/1257)
|
||||
if (editable || isCellEditable != null) {
|
||||
validProps.sortable = false;
|
||||
}
|
||||
|
||||
// handle editors
|
||||
if (editable && validProps.editor == null) {
|
||||
validProps.editor = (args: any) => {
|
||||
const cellArgs = ColumnUtils.getCellArgs(rowDataKeyField, args);
|
||||
const value = cellArgs.rowData[cellArgs.field];
|
||||
if (isCellEditable != null) {
|
||||
if (!isCellEditable(cellArgs)) {
|
||||
return <DataCell>{valueRenderer(value)}</DataCell>;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultInputProps = {
|
||||
autoFocus: true,
|
||||
items: items,
|
||||
itemTextField: itemTextField,
|
||||
itemValueField: itemValueField,
|
||||
defaultValue: value,
|
||||
disableLogging: true, // can add grid cell edit logging later
|
||||
onChange: (value: any) => onCellEditChange(cellArgs, value)
|
||||
};
|
||||
|
||||
return <Dropdown {...{ ...defaultInputProps, ...inputProps }} />;
|
||||
}
|
||||
}
|
||||
if (validProps.editor) {
|
||||
(validProps as any).editorValidatorEvent = "click";
|
||||
}
|
||||
|
||||
// handle filter
|
||||
if (validProps.filter) {
|
||||
validProps.filterMatchMode = validProps.filterMatchMode || "contains";
|
||||
}
|
||||
|
||||
validProps.style = Object.assign({}, validProps.style, {
|
||||
width: width
|
||||
});
|
||||
|
||||
return validProps;
|
||||
}
|
||||
|
||||
|
||||
export default DropDownColumn;
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
import { IColumn } from "./IColumn";
|
||||
|
||||
export interface IEmptyColumnProps {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const EmptyColumn: React.FC<IEmptyColumnProps> & IColumn = (props: IEmptyColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
EmptyColumn.className = "EmptyColumn";
|
||||
|
||||
EmptyColumn.getColumnProps = (props: any): ColumnProps => {
|
||||
return {
|
||||
className: "p-column-empty",
|
||||
style: {
|
||||
width: "*"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default EmptyColumn;
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
import { IColumn } from "./IColumn";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import MinusSquareIcon from "../icon/MinusSquareIcon";
|
||||
import PlusSquareIcon from "../icon/PlusSquareIcon";
|
||||
|
||||
export interface IExpandColumnProps {
|
||||
/** Whether columns are expanded or not */
|
||||
expanded?: boolean;
|
||||
|
||||
/** Called when column header icon is clicked */
|
||||
onToggle?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const ExpandColumn: React.FC<IExpandColumnProps> & IColumn = (props: IExpandColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
ExpandColumn.className = "ExpandColumn";
|
||||
|
||||
ExpandColumn.getColumnProps = (args: IGetColumnPropsArgs): ColumnProps => {
|
||||
const { props } = args;
|
||||
let { expanded, onToggle } = props as IExpandColumnProps;
|
||||
|
||||
return {
|
||||
className: "p-column-expand",
|
||||
header: <span className="p-column-expand-icon" onClick={() => onToggle && onToggle(!expanded)}>
|
||||
{expanded ? (<MinusSquareIcon color="blue-700" size={14} />) : (<PlusSquareIcon color="blue-700" size={14} />)}
|
||||
</span>
|
||||
} as ColumnProps;
|
||||
|
||||
}
|
||||
|
||||
export default ExpandColumn;
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
import { IColumn } from "./IColumn";
|
||||
|
||||
export interface IGapColumnProps {
|
||||
/** Custom body style */
|
||||
bodyStyle?: React.CSSProperties;
|
||||
|
||||
/** Fix the column while scrolling horizontally */
|
||||
frozen?: boolean;
|
||||
|
||||
/** Custom style for all cells */
|
||||
style?: React.CSSProperties;
|
||||
|
||||
/** Column width. Use a number for pixels, string for percent, or "*" to stretch the column */
|
||||
width?: string | number;
|
||||
|
||||
/** Custom column style class */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const GapColumn: React.FC<IGapColumnProps> & IColumn = (props: IGapColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
GapColumn.className = "GapColumn";
|
||||
|
||||
GapColumn.getColumnProps = (props: any): ColumnProps => {
|
||||
let validProps = { ...props };
|
||||
|
||||
validProps.className = "p-column-gap";
|
||||
|
||||
return validProps;
|
||||
}
|
||||
|
||||
export default GapColumn;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { RuleItem } from "async-validator";
|
||||
import ICellArgs from "./ICellArgs";
|
||||
import ICellEditorArgs from "./ICellEditorArgs";
|
||||
|
||||
export interface IBaseColumnProps {
|
||||
/** Text alignment. Default is left. Numbers and dates should be aligned right */
|
||||
align?: "left" | "center" | "right";
|
||||
|
||||
/** Data property */
|
||||
field?: string;
|
||||
|
||||
/** Property used for sorting. Defaults to the field */
|
||||
sortField?: string;
|
||||
|
||||
/** Header text */
|
||||
header?: any;
|
||||
|
||||
/** Custom header style */
|
||||
headerStyle?: React.CSSProperties;
|
||||
|
||||
/** Custom content of cell body for embedding custom controls in a cell. This overrides default cell styles, editing, and selection functionalities. */
|
||||
body?: (rowData: any, column: any) => any;
|
||||
|
||||
/** Custom calculation of cell value only. This still applies default cell styles, editing, and selection functionalities */
|
||||
customCellValue?: (cellArgs: ICellArgs) => any;
|
||||
|
||||
/** Custom body style */
|
||||
bodyStyle?: React.CSSProperties;
|
||||
|
||||
/** Make the column sortable */
|
||||
sortable?: boolean;
|
||||
|
||||
/** Make the column filterable */
|
||||
filter?: boolean;
|
||||
|
||||
/** Defines filterMatchMode: "startsWith", "contains", "endsWidth", "equals", "notEquals", "in" and "custom". Defaults to "contains" */
|
||||
filterMatchMode?: "endsWith" | "startsWith" | "custom" | "lt" | "contains" | "equals" | "notEquals" | "in" | "lte" | "gt" | "gte";
|
||||
|
||||
/** Custom filter function. Use with filterMatchMode="custom" */
|
||||
filterFunction?: (cellValue: any, filterValue: any) => boolean;
|
||||
|
||||
/** Filter input placeholder text */
|
||||
filterPlaceholder?: string;
|
||||
|
||||
/** Filter input type */
|
||||
filterType?: string;
|
||||
|
||||
/** Filter input max length */
|
||||
filterMaxLength?: number;
|
||||
|
||||
/** Custom filter input */
|
||||
filterElement?: object;
|
||||
|
||||
/** Fix the column while scrolling horizontally */
|
||||
frozen?: boolean;
|
||||
|
||||
/** Custom style for all cells */
|
||||
style?: React.CSSProperties;
|
||||
|
||||
/** Column width in pixels */
|
||||
width: number;
|
||||
|
||||
/** Custom column style class */
|
||||
className?: string;
|
||||
|
||||
/** Make cells editable using the default inputs. Will set sortable to false */
|
||||
editable?: boolean;
|
||||
|
||||
/** Manually configure the cell editors */
|
||||
editor?: (cellEditorArgs: ICellEditorArgs) => JSX.Element | undefined;
|
||||
|
||||
/** Conditionally determine if cells are editable. Will set sortable to false */
|
||||
isCellEditable?: (cellEditorArgs: ICellArgs) => boolean;
|
||||
|
||||
/** Conditionally determine if a cell is clickable. Defaults to true if cell is editable */
|
||||
isCellClickable?: (cellEditorArgs: ICellArgs) => boolean;
|
||||
|
||||
/** Validation rules for cell editing */
|
||||
validationRules?: RuleItem[];
|
||||
|
||||
/** For TreeTable: Make the rows expandable */
|
||||
expander?: boolean;
|
||||
|
||||
/** Hide column */
|
||||
hidden?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export default interface ICellArgs {
|
||||
/** The key for this cell. For data grid, it will be same as rowKey + field. For tree grid, it will be the node.key + field */
|
||||
cellKey: string;
|
||||
|
||||
/** The key for the row this cell belongs to. */
|
||||
rowKey: string;
|
||||
|
||||
/** The field for the column */
|
||||
field: string;
|
||||
|
||||
/** The row object. For data grid, it will be same as rowData. For tree grid, it will be the tree node */
|
||||
row: any;
|
||||
|
||||
/** The data for the row. For data grid, it will be same as row. For tree grid, it's tree node.data */
|
||||
rowData: any;
|
||||
|
||||
/** Row index for data grid. For tree grid, this is not available */
|
||||
rowIndex?: number;
|
||||
|
||||
/** Current data for the entire data grid. For tree grid, this is not available */
|
||||
gridData?: any[];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import ICellArgs from "./ICellArgs";
|
||||
|
||||
export default interface ICellClickArgs extends ICellArgs {
|
||||
/** Is the cell editable */
|
||||
isEditable: boolean;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import ICellArgs from "./ICellArgs";
|
||||
|
||||
// just for backward compatibility and in case we add additional props for editing cells
|
||||
export default interface ICellEditorArgs extends ICellArgs {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
|
||||
export interface IColumn {
|
||||
className: string;
|
||||
getColumnProps: (args: IGetColumnPropsArgs) => ColumnProps;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { RuleItem } from "async-validator";
|
||||
import { IInvalidCell } from "../datagrid/DataGrid";
|
||||
import ICellClickArgs from "./ICellClickArgs";
|
||||
import ICellEditorArgs from "./ICellEditorArgs";
|
||||
|
||||
export default interface IGetColumnPropsArgs {
|
||||
props: any;
|
||||
onCellEditChange: (args: ICellEditorArgs, newCellValue: any) => void;
|
||||
onCellClick: (args: ICellClickArgs) => void;
|
||||
onCellValidate: (args: ICellEditorArgs, rules: RuleItem[]) => void;
|
||||
selectedCellKey?: string;
|
||||
dataCellClassName?: string;
|
||||
invalidCells?: IInvalidCell[];
|
||||
rowDataKeyField: string;
|
||||
rowIndexOffset: number;
|
||||
validationMode: 'single-cell' | 'entire-grid' | 'manual';
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as React from "react";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
import intlService, { NumberFormat, IFormatNumberOptions } from "@strata/intl/lib";
|
||||
import { getCurrencySymbol } from "@strata/intl/lib/number/numbro-utils";
|
||||
import DataCell from "../datacell/DataCell";
|
||||
import { IBaseColumnProps } from "./IBaseColumnProps";
|
||||
import InputNumber, { IInputNumberProps } from "../inputnumber/InputNumber";
|
||||
import ColumnUtils from "./ColumnUtils";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import { IColumn } from "./IColumn";
|
||||
|
||||
export interface INumberColumnProps extends IBaseColumnProps {
|
||||
/** NumberFormat from @strata/intl package. Default is "number" */
|
||||
format?: NumberFormat;
|
||||
|
||||
/** Number format options: { nullValue?: string, zeroValue?: string, shortHand?: boolean} */
|
||||
formatOptions?: IFormatNumberOptions;
|
||||
|
||||
/** Number input props for editable cell */
|
||||
inputProps?: IInputNumberProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const NumberColumn: React.FC<INumberColumnProps> & IColumn = (props: INumberColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
NumberColumn.className = "NumberColumn";
|
||||
|
||||
NumberColumn.getColumnProps = (args: IGetColumnPropsArgs) => {
|
||||
const { props, onCellEditChange, onCellValidate, rowDataKeyField, validationMode = 'single-cell' } = args;
|
||||
let { align = "right", width, editable, isCellEditable, formatOptions, validationRules, inputProps = {}, ...validProps } = props as INumberColumnProps;
|
||||
const format = ((props as INumberColumnProps).format || "number");
|
||||
const isHeaderString = (validProps.header != null && typeof validProps.header === 'string');
|
||||
const isFormatCurrencyWithoutSymbol = (format === "currencyNoSymbol" || format === "currencyNoSymbolDecimal" || format === "currencyNoSymbolLong");
|
||||
|
||||
if (isHeaderString && isFormatCurrencyWithoutSymbol) {
|
||||
validProps.header = `${validProps.header} ${getCurrencySymbol()}`;
|
||||
}
|
||||
|
||||
if (validProps.body == null) {
|
||||
validProps.body = ColumnUtils.getCellBody(args, (value) => intlService.formatNumber(value, format, formatOptions));
|
||||
}
|
||||
|
||||
// remove sorting on editable columns (at least until this is solved: https://github.com/primefaces/primereact/issues/1257)
|
||||
if (editable || isCellEditable != null) {
|
||||
validProps.sortable = false;
|
||||
}
|
||||
|
||||
// handle editors
|
||||
if (editable && validProps.editor == null) {
|
||||
validProps.editor = (args: any) => {
|
||||
const cellArgs = ColumnUtils.getCellArgs(rowDataKeyField, args);
|
||||
const value = cellArgs.rowData[cellArgs.field];
|
||||
if (isCellEditable != null) {
|
||||
if (!isCellEditable(cellArgs)) {
|
||||
return <DataCell>{intlService.formatNumber(value, format, formatOptions)}</DataCell>;
|
||||
}
|
||||
}
|
||||
|
||||
const numberType = intlService.getNumberType(format);
|
||||
const defaultInputProps = {
|
||||
format: numberType,
|
||||
value: value,
|
||||
onChange: (value: any) => onCellEditChange(cellArgs, value),
|
||||
onFocus: (e: React.FocusEvent<HTMLInputElement>) => e.target.select()
|
||||
};
|
||||
|
||||
return <InputNumber {...{ ...defaultInputProps, ...inputProps }}></InputNumber>;
|
||||
}
|
||||
}
|
||||
if (validProps.editor) {
|
||||
(validProps as any).editorValidatorEvent = "click";
|
||||
}
|
||||
if (validationRules && validationMode === 'single-cell') {
|
||||
(validProps as ColumnProps).editorValidator = (args: any) => {
|
||||
const cellArgs = ColumnUtils.getCellArgs(rowDataKeyField, args);
|
||||
onCellValidate(cellArgs, validationRules || []);
|
||||
// always return true since we are going to handle this internally in datagrid or treegrid
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// handle filter
|
||||
if (validProps.filter) {
|
||||
validProps.filterMatchMode = validProps.filterMatchMode || "contains";
|
||||
}
|
||||
|
||||
validProps.className = "tempo-number-column";
|
||||
|
||||
validProps.style = Object.assign({}, validProps.style, {
|
||||
textAlign: align,
|
||||
width: width
|
||||
});
|
||||
|
||||
return validProps;
|
||||
}
|
||||
|
||||
|
||||
export default NumberColumn;
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
import { IColumn } from "./IColumn";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
|
||||
export interface IRowNumberColumnProps {
|
||||
/** Custom body style */
|
||||
bodyStyle?: React.CSSProperties;
|
||||
|
||||
/** Fix the column while scrolling horizontally */
|
||||
frozen?: boolean;
|
||||
|
||||
/** Custom style for all cells */
|
||||
style?: React.CSSProperties;
|
||||
|
||||
/** Column width. Use a number for pixels, string for percent, or "*" to stretch the column */
|
||||
width?: string | number;
|
||||
|
||||
/** Custom column style class */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const RowNumberClassName = "p-datatable-rownumber-cell";
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const RowNumberColumn: React.FC<IRowNumberColumnProps> & IColumn = (props: IRowNumberColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
RowNumberColumn.className = "RowNumberColumn";
|
||||
|
||||
RowNumberColumn.getColumnProps = (args: IGetColumnPropsArgs): ColumnProps => {
|
||||
const { props } = args;
|
||||
let { width, ...validProps } = props as any;
|
||||
if (validProps.body == null) {
|
||||
var rowIndexOffset = validProps.rowIndexOffset ?? 0
|
||||
|
||||
validProps.body = (data: any, column: any) => {
|
||||
return column.rowIndex + 1 + rowIndexOffset;
|
||||
};
|
||||
}
|
||||
|
||||
validProps.className = RowNumberClassName;
|
||||
|
||||
validProps.style = Object.assign({}, validProps.style, {
|
||||
textAlign: "center",
|
||||
width: width
|
||||
});
|
||||
|
||||
return validProps;
|
||||
}
|
||||
|
||||
export default RowNumberColumn;
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
import { IColumn } from "./IColumn";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
|
||||
export interface ISelectionColumnProps {
|
||||
/** Whether to use radiobutton (single) or checkbox (multiple) selection mode. Defaults to multiple. */
|
||||
selectionMode?: 'single' | 'multiple';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const SelectionColumn: React.FC<ISelectionColumnProps> & IColumn = (props: ISelectionColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
SelectionColumn.className = "SelectionColumn";
|
||||
|
||||
SelectionColumn.getColumnProps = (args: IGetColumnPropsArgs): ColumnProps => {
|
||||
return {
|
||||
className: "",
|
||||
selectionMode: args.props.selectionMode ?? 'multiple'
|
||||
};
|
||||
}
|
||||
|
||||
export default SelectionColumn;
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
import DataCell from "../datacell/DataCell";
|
||||
import ColumnUtils from "./ColumnUtils";
|
||||
import DataColumn from "../datacolumn";
|
||||
import InputTextArea from "../inputtextarea";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import { IColumn } from "./IColumn";
|
||||
import { IBaseColumnProps } from "./IBaseColumnProps";
|
||||
|
||||
|
||||
export interface ITextAreaColumnProps extends IBaseColumnProps {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const TextAreaColumn: React.FC<ITextAreaColumnProps> & IColumn = (props: ITextAreaColumnProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The column component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
TextAreaColumn.className = "TextAreaColumn";
|
||||
|
||||
TextAreaColumn.getColumnProps = (args: IGetColumnPropsArgs) => {
|
||||
const { props, onCellEditChange, dataCellClassName = "tempo-datacell--textarea", rowDataKeyField } = args;
|
||||
const { editable, editor, isCellEditable } = props;
|
||||
|
||||
// TextAreaColumn is very similar to regular column so we can just call that and make the small change to the editor
|
||||
args.dataCellClassName = dataCellClassName; // ensure cell class name is set to tempo-datacell--textarea
|
||||
const validProps = DataColumn.getColumnProps(args);
|
||||
|
||||
// handle editors
|
||||
if (editable && editor == null) {
|
||||
validProps.editor = (args: any) => {
|
||||
const cellEditArgs = ColumnUtils.getCellArgs(rowDataKeyField, args);
|
||||
const value = cellEditArgs.rowData[cellEditArgs.field];
|
||||
if (isCellEditable != null) {
|
||||
if (!isCellEditable(cellEditArgs)) {
|
||||
return <DataCell className={dataCellClassName}>{value}</DataCell>;
|
||||
}
|
||||
}
|
||||
return <InputTextArea autoFocus autoSize={{ minRows: 1, maxRows: 3 }} defaultValue={value}
|
||||
onChange={(e) => onCellEditChange(cellEditArgs, e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
return validProps;
|
||||
}
|
||||
|
||||
|
||||
export default TextAreaColumn;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { IBaseColumnProps } from "./IBaseColumnProps";
|
||||
import { IColumn } from "./IColumn";
|
||||
import DataColumn, { IDataColumnProps } from "./DataColumn";
|
||||
import RowNumberColumn, { IRowNumberColumnProps } from "./RowNumberColumn";
|
||||
import NumberColumn, { INumberColumnProps } from "./NumberColumn";
|
||||
import DateColumn, { IDateColumnProps } from "./DateColumn";
|
||||
import CheckboxColumn, { ICheckboxColumnProps } from "./CheckboxColumn";
|
||||
import GapColumn, { IGapColumnProps } from "./GapColumn";
|
||||
import TextAreaColumn, { ITextAreaColumnProps } from "./TextAreaColumn";
|
||||
import EmptyColumn, { IEmptyColumnProps } from "./EmptyColumn";
|
||||
import DropDownColumn, { IDropDownColumnProps } from "./DropDownColumn";
|
||||
import DragDropColumn, { IDragDropColumnProps } from "./DragDropColumn";
|
||||
import SelectionColumn, { ISelectionColumnProps } from "./SelectionColumn";
|
||||
import ExpandColumn, { IExpandColumnProps} from "./ExpandColumn";
|
||||
import ICellArgs from "./ICellArgs";
|
||||
import ICellEditorArgs from "./ICellEditorArgs";
|
||||
import ICellClickArgs from "./ICellClickArgs";
|
||||
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
|
||||
import { RuleItem } from "async-validator";
|
||||
import { ColumnProps } from "primereact/column";
|
||||
|
||||
export default DataColumn;
|
||||
export {
|
||||
IColumn, IBaseColumnProps, IGetColumnPropsArgs, ColumnProps,
|
||||
IDataColumnProps, ICellArgs, ICellClickArgs, ICellEditorArgs,
|
||||
RowNumberColumn, IRowNumberColumnProps,
|
||||
NumberColumn, INumberColumnProps,
|
||||
DateColumn, IDateColumnProps,
|
||||
CheckboxColumn, ICheckboxColumnProps,
|
||||
TextAreaColumn, ITextAreaColumnProps,
|
||||
GapColumn, IGapColumnProps,
|
||||
EmptyColumn, IEmptyColumnProps,
|
||||
DropDownColumn, IDropDownColumnProps,
|
||||
DragDropColumn, IDragDropColumnProps,
|
||||
SelectionColumn, ISelectionColumnProps,
|
||||
ExpandColumn, IExpandColumnProps,
|
||||
RuleItem
|
||||
};
|
||||
@@ -0,0 +1,694 @@
|
||||
import * as React from "react";
|
||||
import { DataTable, DataTableProps } from "primereact/datatable"
|
||||
import DataColumn, {
|
||||
RowNumberColumn, NumberColumn, DateColumn, CheckboxColumn,
|
||||
GapColumn, TextAreaColumn, DropDownColumn, EmptyColumn, DragDropColumn, SelectionColumn, ExpandColumn,
|
||||
ICellClickArgs, ICellEditorArgs, IDateColumnProps, IDropDownColumnProps, IBaseColumnProps
|
||||
} from "../datacolumn";
|
||||
import DataRowClass from "./DataRowClass";
|
||||
import DataHeaderUtils from "./../dataheader/DataHeaderUtils";
|
||||
import ColumnUtils from "../datacolumn/ColumnUtils";
|
||||
import Loader from "../loader/Loader";
|
||||
import { RuleItem } from "async-validator";
|
||||
import DataGridValidator from "./DataGridValidator";
|
||||
import DataGridGlobalFilter from "./DataGridGlobalFilter";
|
||||
import TotalRowCalculator from "./TotalRowCalculator";
|
||||
import dayjs from "dayjs";
|
||||
import intlService from "@strata/intl/lib";
|
||||
import IGetColumnPropsArgs from "../datacolumn/IGetColumnPropsArgs";
|
||||
import { logger } from "@strata/logging/lib";
|
||||
|
||||
export interface ITotalRowCalculation {
|
||||
/** Data property of the column */
|
||||
field: string;
|
||||
|
||||
/** How value is calculated. If set to custom, make sure to set customCalculation prop */
|
||||
calculationType: "sum" | "avg" | "custom";
|
||||
|
||||
/** Custom function to calculate total data for this field. Values is an array of values for the field. allData is original grid data */
|
||||
customCalculation?: (fieldValues: any[], allData: any[]) => number;
|
||||
}
|
||||
|
||||
export interface IGlobalFilterValue {
|
||||
/** List of fields that global filter applies to */
|
||||
fields: string[];
|
||||
|
||||
/** Search value to apply "contains" logic filtering */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IDataGridSelectionEvent {
|
||||
/** Browser click event */
|
||||
originalEvent: Event;
|
||||
|
||||
/** Selected rows */
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface IDataGridSortEvent {
|
||||
/** Column that is being sorted on */
|
||||
sortField: string;
|
||||
|
||||
/** 1 for asc, -1 for desc */
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface IDataGridPageEvent {
|
||||
/** The index of the first row of the current page. */
|
||||
first: number;
|
||||
}
|
||||
|
||||
export interface IDataGridFilterEvent {
|
||||
/** Object containing a property with name equal to current column's field. For example, when filtering on 'description' column, filters = {description: {value:'searchtext'}} */
|
||||
filters: any;
|
||||
}
|
||||
|
||||
export interface IDataGridTotalCellMetaData {
|
||||
/** Name of column field, will match supplied metadata to the column of the same field name */
|
||||
field: string
|
||||
/** text color of the total cell */
|
||||
textColor?: "error" | "success"
|
||||
}
|
||||
|
||||
export interface IDataGridBaseProps {
|
||||
/** Unique identifier */
|
||||
id?: string;
|
||||
|
||||
/** Css class name */
|
||||
className?: string;
|
||||
|
||||
/** Grid data */
|
||||
value?: any[];
|
||||
|
||||
/** Placeholder text when there is no data */
|
||||
emptyMessage?: React.ReactNode;
|
||||
|
||||
/** Column configuration */
|
||||
children?: React.ReactElement[] | React.ReactElement;
|
||||
|
||||
/** Make columns resizable. Default is true */
|
||||
resizableColumns?: boolean;
|
||||
|
||||
/** Default sort field. Must be used with sortOrder */
|
||||
sortField?: string;
|
||||
|
||||
/** Default sort order. Must be used with sortField. 1: Ascending, -1: Descending */
|
||||
sortOrder?: number;
|
||||
|
||||
/** Sort on a single column or on multiple columns. Default is "single" */
|
||||
sortMode?: string;
|
||||
|
||||
/** Default sort order of an unsorted column */
|
||||
defaultSortOrder?: number;
|
||||
|
||||
/** Selected cell key */
|
||||
selectedCellKey?: string;
|
||||
|
||||
/** Enable pagination. Default is true */
|
||||
paginator?: boolean;
|
||||
|
||||
/** Number of rows per page. Default is 25 */
|
||||
rows?: number;
|
||||
|
||||
/** The index of the first row of the current page. This needs to be set when grid is in lazy mode. */
|
||||
first?: number;
|
||||
|
||||
/** The total number of rows in the grid. This needs to be set where grid is in lazy mode. */
|
||||
totalRecords?: number;
|
||||
|
||||
/** Text that appears in pager. Defaults to 'items'. */
|
||||
itemName?: string;
|
||||
|
||||
/** Extra content to right of pager */
|
||||
paginatorRight?: React.ReactNode;
|
||||
|
||||
/** Called when page changes */
|
||||
onPage?: (e: IDataGridPageEvent) => void;
|
||||
|
||||
/** Called on cell edit */
|
||||
onCellEdit?: (cellEditArgs: ICellEditorArgs, newCellValue: any, oldCellValue: any) => void;
|
||||
|
||||
/** Called on cell click */
|
||||
onCellClick?: (args: ICellClickArgs) => void;
|
||||
|
||||
/** Called when validation changes */
|
||||
onValidationChange?: (invalidCells: IInvalidCell[]) => void;
|
||||
|
||||
/** Defaults to 'single-cell' which validates the cell when it is changed. 'all-cells' will validate all grid cells when one cell is changed. 'manual' will not validate any cells until validateGrid function is called. */
|
||||
validationMode?: 'single-cell' | 'all-cells' | 'manual';
|
||||
|
||||
/** Grid density. Dense reduces padding. Minimal reduces padding and removes borders. Default is "normal" */
|
||||
density?: "normal" | "dense" | "minimal";
|
||||
|
||||
/** Add border to the top of the header and/or after the last row. E.g. "top", "bottom", ["top", "bottom"] */
|
||||
border?: string | string[];
|
||||
|
||||
/** Total row data. May contain sums or averages. If the value can be represented as a sum or an average, represent it as a sum to avoid confusion. For example, in a grid of total cost and total cost per case with departments as rows, our total row could sum the total cost and average the total cost per case. */
|
||||
totalRowData?: object;
|
||||
|
||||
/** Custom label for total row. Use sparingly */
|
||||
totalRowLabel?: string;
|
||||
|
||||
/** Href for total row. Cannot be used with totalRowOnClick. Use sparingly */
|
||||
totalRowHref?: string;
|
||||
|
||||
/** OnClick event for total row. Cannot be used with totalRowHref. Use sparingly */
|
||||
totalRowOnClick?: React.MouseEventHandler<HTMLElement>;
|
||||
|
||||
/** DataHeaderGroup to group non frozen column headers */
|
||||
columnHeaderGroup?: React.ReactElement;
|
||||
|
||||
/** DataHeaderGroup to group frozen column headers */
|
||||
frozenColumnHeaderGroup?: React.ReactElement;
|
||||
|
||||
/** Function that takes the row data and returns an object in "{'styleclass' : condition}" format to define a className for a particular row */
|
||||
getRowClassName?: (data: any) => object;
|
||||
|
||||
/** Show loading mask */
|
||||
loading?: boolean;
|
||||
|
||||
/** Configuration to calculate total row data automatically */
|
||||
totalRowCalculations?: ITotalRowCalculation[];
|
||||
|
||||
/** List of objects containing metadata for total cells, the meta data will be matched to the total cell by field name. Metadata has the shape: { field: string, textColor?: "success" | "error" } */
|
||||
totalRowCellsMetaData?: IDataGridTotalCellMetaData[];
|
||||
|
||||
/** Filter to apply to multiple columns */
|
||||
globalFilterValue?: IGlobalFilterValue;
|
||||
|
||||
/** Enable vertical scrolling for grid */
|
||||
scrollable?: boolean;
|
||||
|
||||
/** Offset vertical scrolling height to account for a fixed action bar, etc. Used for full width grid. (fixed action bar default height is 53px) */
|
||||
scrollableOffset?: number;
|
||||
|
||||
/** Set fixed height for grid in modal, drawer, or card. This overrides automatic height calculation for full width grid. Accepts css height format, ex '400px'*/
|
||||
scrollHeight?: string;
|
||||
|
||||
/** Additional log data */
|
||||
logData?: object;
|
||||
|
||||
/** Turn off logging. Default to false */
|
||||
disableLogging?: boolean;
|
||||
|
||||
/** Turn on for large datasets that require server side paging, sorting, filtering */
|
||||
lazy?: boolean;
|
||||
|
||||
/** Called when sort changes */
|
||||
onSort?: (e: IDataGridSortEvent) => void;
|
||||
|
||||
/** Column filters for grid in lazy mode. Should match IDataGridFilterEvent.filters from onFilter */
|
||||
filters?: any;
|
||||
|
||||
/** Called when column filter changes */
|
||||
onFilter?: (e: IDataGridFilterEvent) => void;
|
||||
|
||||
/** Display ellipsis when column header exceeds one row. Default is false. Use exclusively in grids with frozen columns. */
|
||||
truncateColumnHeaders?: boolean;
|
||||
|
||||
}
|
||||
|
||||
export interface IDataGridProps extends IDataGridBaseProps {
|
||||
/** Data property name to uniquely identify a record. */
|
||||
dataKey?: string;
|
||||
|
||||
/** Array of row dataKey values that should be expanded */
|
||||
expandedRowKeys?: any[];
|
||||
|
||||
/** Function that receives the row data as the parameter and returns the expanded row content */
|
||||
rowExpansionTemplate?: (rowData: any) => any;
|
||||
|
||||
/** Called when the grid is filtered or sorted */
|
||||
onDataChange?: (data: any) => void;
|
||||
|
||||
/** File name of export CSV file. Defaults to 'Export'. YYYY-MM-DD HH:mm:ss will always be appended. */
|
||||
exportFilename?: string;
|
||||
|
||||
/** Called when a row has been reordered via drag and drop */
|
||||
onRowReorder?(e: { originalEvent: Event, value: any, dragIndex: number, dropIndex: number }): void;
|
||||
|
||||
/** Selected rows */
|
||||
selection?: any;
|
||||
|
||||
/** Called when row selection changes */
|
||||
onSelectionChange?: (e: IDataGridSelectionEvent) => void;
|
||||
|
||||
/** Function that returns a boolean by passing the row data to decide if the radio or checkbox should be displayed per row. */
|
||||
showSelectionElement?: (row: any) => boolean;
|
||||
|
||||
/** Hide select all checkbox in column header when using checkbox row selection */
|
||||
hideSelectAll?: boolean;
|
||||
|
||||
/** Whether to show reorder icon for a row */
|
||||
showRowReorderElement?: (row: any) => boolean;
|
||||
|
||||
}
|
||||
|
||||
interface IDataGridState {
|
||||
invalidCells: IInvalidCell[];
|
||||
refreshCounter: number;
|
||||
bodyOffsetTop?: number;
|
||||
}
|
||||
|
||||
export interface IInvalidCell {
|
||||
cellKey: string;
|
||||
rowKey: string;
|
||||
field: string;
|
||||
value: any;
|
||||
validationMessages: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Grid displays rows of data in a table
|
||||
* @beta
|
||||
*/
|
||||
class DataGrid extends React.Component<IDataGridProps, IDataGridState> {
|
||||
static Column = DataColumn;
|
||||
static RowNumber = RowNumberColumn;
|
||||
static NumberColumn = NumberColumn;
|
||||
static CheckboxColumn = CheckboxColumn;
|
||||
static DateColumn = DateColumn;
|
||||
static RowClass = DataRowClass;
|
||||
static GapColumn = GapColumn;
|
||||
static TextAreaColumn = TextAreaColumn;
|
||||
static EmptyColumn = EmptyColumn;
|
||||
static DropDownColumn = DropDownColumn;
|
||||
static DragDropColumn = DragDropColumn;
|
||||
static SelectionColumn = SelectionColumn;
|
||||
static ExpandColumn = ExpandColumn;
|
||||
|
||||
static DefaultProps: IDataGridProps = {
|
||||
density: "normal"
|
||||
}
|
||||
|
||||
grid: React.RefObject<DataTable>;
|
||||
|
||||
constructor(props: IDataGridProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
invalidCells: [],
|
||||
refreshCounter: 0
|
||||
};
|
||||
this.onCellEditChange = this.onCellEditChange.bind(this);
|
||||
this.onDataChange = this.onDataChange.bind(this);
|
||||
this.onRowClassName = this.onRowClassName.bind(this);
|
||||
this.onCellClick = this.onCellClick.bind(this);
|
||||
this.onCellValidate = this.onCellValidate.bind(this);
|
||||
this.onCellExport = this.onCellExport.bind(this);
|
||||
|
||||
this.grid = React.createRef<DataTable>();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: IDataGridProps) {
|
||||
// track if row count changed
|
||||
const prevRowCount = prevProps.value ? prevProps.value.length : 0;
|
||||
const curRowCount = this.props.value ? this.props.value.length : 0;
|
||||
const rowCountChanged = prevRowCount != curRowCount;
|
||||
|
||||
if (rowCountChanged) {
|
||||
if (this.props.validationMode === 'single-cell') {
|
||||
this.cleanInvalidCells();
|
||||
} else if (this.props.validationMode === 'all-cells') {
|
||||
this.validateGridAndNotify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.scrollable && this.props.scrollHeight == null) {
|
||||
setTimeout(() => {
|
||||
this.calculateBodyOffsetTop();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (this.props.validationMode === 'all-cells') {
|
||||
this.validateGridAndNotify();
|
||||
}
|
||||
}
|
||||
|
||||
cleanInvalidCells() {
|
||||
const data = this.props.value || [];
|
||||
const invalidCells = this.state.invalidCells;
|
||||
const rowDataKeyField = this.props.dataKey;
|
||||
|
||||
if (rowDataKeyField == null) { return; }
|
||||
|
||||
// get list of rowKeys
|
||||
const rowKeys = data.map(item => item[rowDataKeyField]);
|
||||
|
||||
// remove invalidCells that are orphaned
|
||||
const newInvalidCells = invalidCells.filter(item => rowKeys.indexOf(item.rowKey) > -1);
|
||||
|
||||
if (newInvalidCells.length != invalidCells.length) {
|
||||
|
||||
this.setState({
|
||||
invalidCells: newInvalidCells
|
||||
});
|
||||
if (this.props.onValidationChange) {
|
||||
this.props.onValidationChange(newInvalidCells);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calculateBodyOffsetTop() {
|
||||
try {
|
||||
const container = ((this.grid.current as any)?.container as HTMLElement);
|
||||
if (container) {
|
||||
const containerTop = container.getBoundingClientRect().top + window.pageYOffset;
|
||||
|
||||
// gnarly but this is the only way to get access to header dom
|
||||
const headerHeight = (container.children[0]?.children[0]?.children[0] as HTMLElement)?.offsetHeight;
|
||||
|
||||
this.setState({
|
||||
bodyOffsetTop: containerTop + headerHeight
|
||||
});
|
||||
}
|
||||
|
||||
} catch {
|
||||
// do nothing if we can't access dom
|
||||
}
|
||||
}
|
||||
|
||||
onDataChange(values: []) {
|
||||
if (values != null) {
|
||||
if (this.props.onDataChange) {
|
||||
this.props.onDataChange(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onCellEditChange(editArgs: ICellEditorArgs, newCellValue: any) {
|
||||
if (editArgs.rowIndex != null) {
|
||||
const oldValue = editArgs.rowData[editArgs.field];
|
||||
if (oldValue != newCellValue) {
|
||||
editArgs.rowData[editArgs.field] = newCellValue;
|
||||
|
||||
if (this.props.onCellEdit) {
|
||||
this.props.onCellEdit(editArgs, newCellValue, oldValue);
|
||||
}
|
||||
|
||||
if (this.props.validationMode === 'all-cells') {
|
||||
this.validateGridAndNotify();
|
||||
}
|
||||
|
||||
this.refreshGrid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCellClick(cellArgs: ICellClickArgs) {
|
||||
if (this.props.onCellClick) {
|
||||
this.props.onCellClick(cellArgs);
|
||||
}
|
||||
}
|
||||
|
||||
onCellValidate(cellArgs: ICellEditorArgs, rules: RuleItem[]) {
|
||||
var gridRef = this;
|
||||
DataGridValidator.validateCell(cellArgs, rules).then(invalidCell => {
|
||||
const newInvalidCells = DataGridValidator.updateExistingInvalidCells(cellArgs.cellKey, invalidCell, gridRef.state.invalidCells);
|
||||
|
||||
gridRef.setState({
|
||||
invalidCells: newInvalidCells
|
||||
});
|
||||
|
||||
if (gridRef.props.onValidationChange) {
|
||||
gridRef.props.onValidationChange(newInvalidCells);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
onRowClassName(rowData: any): any {
|
||||
const classNames = {};
|
||||
if (this.props.getRowClassName) {
|
||||
Object.assign(classNames, this.props.getRowClassName(rowData));
|
||||
}
|
||||
return classNames;
|
||||
}
|
||||
|
||||
transformChildren(): React.ReactElement[] {
|
||||
// transform children props. We have to do this here because Columns are not rendered. They are pass through containers for props only.
|
||||
// so any modification to props have to happen here.
|
||||
const validChildren: React.ReactElement[] = [];
|
||||
|
||||
// if lazy loading, primereact incorrectly reports rowIndex = 0 for the first row in subsequent pages.
|
||||
// this rowIndexOffset will allow RowNumber column to correctly calculate the row index
|
||||
const rowIndexOffset = this.props.lazy ? this.props.first : 0;
|
||||
React.Children.forEach(this.props.children || [], (child: React.ReactElement, index) => {
|
||||
if (child == null) { return; }
|
||||
|
||||
let props = child.props;
|
||||
if (!!props.hidden) { return; }
|
||||
|
||||
if (child.type && (child.type as any).getColumnProps) {
|
||||
props = (child.type as any).getColumnProps({
|
||||
props: props,
|
||||
onCellEditChange: this.onCellEditChange,
|
||||
onCellClick: this.onCellClick,
|
||||
onCellValidate: this.onCellValidate,
|
||||
selectedCellKey: this.props.selectedCellKey,
|
||||
invalidCells: this.state.invalidCells,
|
||||
rowDataKeyField: this.props.dataKey || "key",
|
||||
rowIndexOffset: rowIndexOffset,
|
||||
validationMode: this.props.validationMode
|
||||
} as IGetColumnPropsArgs);
|
||||
props.key = props.key || props.field || "column" + index;
|
||||
props.exportable = true;
|
||||
}
|
||||
|
||||
validChildren.push(React.cloneElement(child, props));
|
||||
});
|
||||
return validChildren;
|
||||
}
|
||||
|
||||
mergeProps(validChildren: any[]): DataTableProps {
|
||||
const { density, border = [], expandedRowKeys, value = [], columnHeaderGroup, frozenColumnHeaderGroup,
|
||||
totalRowLabel, totalRowHref, totalRowOnClick, totalRowCellsMetaData, className = "",
|
||||
totalRowCalculations, globalFilterValue, hideSelectAll, itemName = "items", truncateColumnHeaders, ...validProps } = this.props;
|
||||
let { totalRowData } = this.props;
|
||||
const hasRowNumber = ColumnUtils.hasRowNumberColumn(validProps);
|
||||
const hasRowReorder = validProps.onRowReorder != null;
|
||||
const hasHeaders = ColumnUtils.hasHeaders(validProps);
|
||||
let rowHover = true;
|
||||
|
||||
const classNames = [];
|
||||
if (className != null && className !== "") {
|
||||
classNames.push(className);
|
||||
}
|
||||
|
||||
// apply default props
|
||||
const defaultProps: DataTableProps = {
|
||||
resizableColumns: true,
|
||||
columnResizeMode: "expand",
|
||||
className: "",
|
||||
virtualRowHeight: 49,
|
||||
emptyMessage: "No items",
|
||||
exportFilename: "Export"
|
||||
};
|
||||
|
||||
// apply global filter to gridData
|
||||
if (globalFilterValue != null) {
|
||||
defaultProps.value = DataGridGlobalFilter.applyFilter(value, globalFilterValue, validChildren);
|
||||
} else {
|
||||
defaultProps.value = value;
|
||||
}
|
||||
|
||||
// calculate totalRowData
|
||||
if (totalRowCalculations != null && totalRowData == null) {
|
||||
// make sure to use defaultProps.value to account for global filter
|
||||
totalRowData = TotalRowCalculator.calculateTotalRow(defaultProps.value, totalRowCalculations);
|
||||
}
|
||||
|
||||
if (validProps.paginator) {
|
||||
classNames.push("p-datatable--pager");
|
||||
if (validProps.rows == null) {
|
||||
validProps.rows = 25; // default page size;
|
||||
}
|
||||
defaultProps.paginatorTemplate = "PrevPageLink PageLinks NextPageLink CurrentPageReport";
|
||||
defaultProps.currentPageReportTemplate = `{totalRecords} total ${itemName}`;
|
||||
}
|
||||
|
||||
// frozen columns
|
||||
const frozenWidth = ColumnUtils.calculateFrozenWidth(this.props);
|
||||
if (frozenWidth > 0) {
|
||||
rowHover = false;
|
||||
|
||||
defaultProps.frozenWidth = frozenWidth + 'px';
|
||||
defaultProps.rowHover = false;
|
||||
defaultProps.resizableColumns = false; // bug in PrimeReact with frozen columns and resizing (9/13/2021)
|
||||
}
|
||||
|
||||
if (rowHover) {
|
||||
if (ColumnUtils.hasSelectionColumn(validProps)) {
|
||||
classNames.push("p-datatable--row-hover-selectable");
|
||||
} else {
|
||||
classNames.push("p-datatable--row-hover");
|
||||
}
|
||||
}
|
||||
|
||||
if (density === "dense") {
|
||||
classNames.push("p-datatable--dense");
|
||||
defaultProps.virtualRowHeight = 39;
|
||||
}
|
||||
|
||||
if (density === "minimal") {
|
||||
classNames.push("p-datatable--minimal");
|
||||
defaultProps.virtualRowHeight = 39;
|
||||
}
|
||||
|
||||
const borderArray = (typeof border === "string") ? [border] : border;
|
||||
if (borderArray.indexOf("top") > -1) {
|
||||
classNames.push("p-datatable--show-top-border");
|
||||
}
|
||||
if (validProps.paginator || (borderArray.indexOf("bottom") > -1)) {
|
||||
classNames.push("p-datatable--show-bottom-border");
|
||||
}
|
||||
|
||||
if (hasRowNumber) {
|
||||
classNames.push("p-datatable--numbered");
|
||||
}
|
||||
|
||||
if (!hasHeaders) {
|
||||
classNames.push("p-datatable--no-header");
|
||||
}
|
||||
|
||||
if (hasRowReorder) {
|
||||
classNames.push("p-datatable--reorderable");
|
||||
}
|
||||
|
||||
if (hideSelectAll) {
|
||||
classNames.push("p-datatable--hide-select-all");
|
||||
}
|
||||
|
||||
if (truncateColumnHeaders) {
|
||||
classNames.push("p-datatable--truncate-header");
|
||||
}
|
||||
|
||||
if (expandedRowKeys) {
|
||||
const expandedRows: any = {};
|
||||
expandedRowKeys.forEach(key => {
|
||||
expandedRows[String(key)] = true;
|
||||
});
|
||||
defaultProps.expandedRows = expandedRows;
|
||||
}
|
||||
|
||||
const headerColumns = DataHeaderUtils.getHeaderColumnGroups(totalRowData, { totalRowLabel, totalRowHref, totalRowOnClick }, columnHeaderGroup, validChildren, hasRowNumber, totalRowCellsMetaData);
|
||||
if (headerColumns) {
|
||||
defaultProps.headerColumnGroup = headerColumns;
|
||||
}
|
||||
|
||||
const frozenHeaderColumns = DataHeaderUtils.getFrozenHeaderColumnGroups(totalRowData, { totalRowLabel, totalRowHref, totalRowOnClick }, frozenColumnHeaderGroup, validChildren, hasRowNumber, totalRowCellsMetaData);
|
||||
if (frozenHeaderColumns) {
|
||||
defaultProps.frozenHeaderColumnGroup = frozenHeaderColumns;
|
||||
}
|
||||
|
||||
defaultProps.exportFilename = defaultProps.exportFilename + "_" + dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
// if row reorder is on, you should not be sorting the grid
|
||||
if (hasRowReorder) {
|
||||
defaultProps.sortField = undefined;
|
||||
}
|
||||
|
||||
// if grid is scrollable and we know the offset top, we can calculate the scroll height
|
||||
if (validProps.scrollable && this.state.bodyOffsetTop != null) {
|
||||
classNames.push("p-datatable--scrollable");
|
||||
|
||||
const pagerHeight = validProps.paginator ? 52 : 0;
|
||||
const offsetHeight = this.props.scrollableOffset ? this.props.scrollableOffset : 0;
|
||||
|
||||
defaultProps.scrollHeight = `calc(100vh - ${this.state.bodyOffsetTop + pagerHeight + offsetHeight}px)`;
|
||||
}
|
||||
|
||||
defaultProps.className = classNames.join(" ");
|
||||
return Object.assign(defaultProps, validProps);
|
||||
}
|
||||
|
||||
|
||||
refreshGrid(): void {
|
||||
// manually trigger state change to refresh grid
|
||||
this.setState({
|
||||
refreshCounter: this.state.refreshCounter + 1
|
||||
});
|
||||
}
|
||||
|
||||
// custom function to control what gets exported by datagrid
|
||||
onCellExport(e: { data: any, field: string }): any {
|
||||
let validCellData = e.data;
|
||||
|
||||
// handle dropdown and date columns
|
||||
React.Children.forEach(this.props.children || [], (child: React.ReactElement) => {
|
||||
if (!child.props.hidden && child.props.field === e.field) {
|
||||
const className = (child.type as any)?.className;
|
||||
if (className === DropDownColumn.className) {
|
||||
const dropDownProps = child.props as IDropDownColumnProps;
|
||||
if (dropDownProps.items != null) {
|
||||
const { itemValueField = "value", itemTextField = "text" } = dropDownProps;
|
||||
// for dropdown columns, we need to resolve the value to display text
|
||||
const dropDownItem = dropDownProps.items.find(item => item[itemValueField] === validCellData)
|
||||
if (dropDownItem) {
|
||||
validCellData = dropDownItem[itemTextField] || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (className === DateColumn.className) {
|
||||
const dateColumnProps = child.props as IDateColumnProps;
|
||||
validCellData = intlService.formatDate(validCellData, dateColumnProps.format || "date");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
return String(validCellData).replace(/"/g, '""');
|
||||
}
|
||||
|
||||
exportCSV(): void {
|
||||
logger.log("grid export", "", this.props.logData);
|
||||
this.grid.current?.exportCSV({ selectionOnly: false });
|
||||
}
|
||||
|
||||
async validateGrid(): Promise<IInvalidCell[]> {
|
||||
const validChildren = this.transformChildren();
|
||||
const columnProps = validChildren.map(column => column.props as IBaseColumnProps).filter(item => !item.hidden);
|
||||
const invalidCells = await DataGridValidator.validateData(this.props.value || [], columnProps, this.props.dataKey || "key");
|
||||
this.setState({
|
||||
invalidCells: invalidCells
|
||||
});
|
||||
return invalidCells;
|
||||
}
|
||||
|
||||
clearInvalidCells() {
|
||||
this.setState({
|
||||
invalidCells: []
|
||||
});
|
||||
}
|
||||
|
||||
validateGridAndNotify() {
|
||||
this.validateGrid().then(invalidCells => {
|
||||
if (this.props.onValidationChange) {
|
||||
this.props.onValidationChange(invalidCells);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
const validChildren = this.transformChildren();
|
||||
const { loading, ...validProps } = this.mergeProps(validChildren);
|
||||
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
<DataTable
|
||||
{...validProps}
|
||||
ref={this.grid}
|
||||
rowClassName={this.onRowClassName}
|
||||
onValueChange={this.onDataChange}
|
||||
exportFunction={this.onCellExport}
|
||||
>
|
||||
{validChildren}
|
||||
</DataTable>
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataGrid;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import DateColumn from "../datacolumn/DateColumn";
|
||||
import DataGridGlobalFilter from "./DataGridGlobalFilter";
|
||||
|
||||
describe('DataGridGlobalFilter Tests', () => {
|
||||
const data = [
|
||||
{ name: "Carmen", department: "Engineering", number: 25, date: new Date(2022, 3, 2) },
|
||||
{ name: "Stelios", department: "UX", number: 30, date: new Date(2022, 4, 1) },
|
||||
{ name: "Madeline", department: "UX", number: 20, date: new Date(2022, 11, 25) }
|
||||
];
|
||||
|
||||
test('applyFilter filters multiple fields', () => {
|
||||
var result = DataGridGlobalFilter.applyFilter(data, {
|
||||
fields: ["name", "department"],
|
||||
value: "El" // test for case sensitivity too
|
||||
}, []);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
test('applyFilter filters numeric field', () => {
|
||||
var result = DataGridGlobalFilter.applyFilter(data, {
|
||||
fields: ["number"],
|
||||
value: "2"
|
||||
}, []);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
test('applyFilter filters date field', () => {
|
||||
var result = DataGridGlobalFilter.applyFilter(data, {
|
||||
fields: ["name", "date"],
|
||||
value: "05/01/2022"
|
||||
}, [<DateColumn field='date' format='date' width={200}></DateColumn>]);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe("Stelios");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
import { IGlobalFilterValue } from "./DataGrid";
|
||||
import intlService from "@strata/intl/lib";
|
||||
import ColumnUtils from "../datacolumn/ColumnUtils";
|
||||
import { IDateColumnProps } from "../datacolumn";
|
||||
|
||||
const DataGridGlobalFilter = {
|
||||
applyFilter(value: any[], globalFilterValue: IGlobalFilterValue, columns: React.ReactElement[]) {
|
||||
if (globalFilterValue == null || globalFilterValue.value == null || globalFilterValue.value === "" || globalFilterValue.fields.length == 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const dateColumnProps = ColumnUtils.getColumnPropsByColumnType<IDateColumnProps>(columns, "DateColumn").filter(column => globalFilterValue.fields.includes(column.field ?? ''));
|
||||
|
||||
// escape special regex characters like [] and ()
|
||||
var escapedValue = globalFilterValue.value.trim().replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
|
||||
const matchExp = new RegExp(escapedValue, "i");
|
||||
|
||||
// return only records that contain the global filter value in one of its fields
|
||||
return value.filter(
|
||||
record => globalFilterValue.fields.findIndex(
|
||||
field => {
|
||||
let cellValue = record[field];
|
||||
if (dateColumnProps.length > 0) {
|
||||
const dateColumn = dateColumnProps.find(c => c.field === field);
|
||||
if (dateColumn) {
|
||||
cellValue = intlService.formatDate(cellValue, dateColumn.format || "date");
|
||||
}
|
||||
} else {
|
||||
cellValue = String(cellValue);
|
||||
}
|
||||
return matchExp.test(cellValue);
|
||||
}
|
||||
) > -1);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataGridGlobalFilter;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { IBaseColumnProps, ICellArgs, ICellEditorArgs } from "../datacolumn";
|
||||
import DataGrid, { IInvalidCell } from "./DataGrid";
|
||||
import Validator, { RuleItem, ValidateOption } from "async-validator";
|
||||
import ColumnUtils from "../datacolumn/ColumnUtils";
|
||||
|
||||
export interface IValidateOptions extends ValidateOption {
|
||||
messages?: any;
|
||||
cellArgs?: ICellArgs
|
||||
}
|
||||
|
||||
const DataGridValidator = {
|
||||
async validateCell(cellArgs: ICellEditorArgs, rules: RuleItem[]): Promise<IInvalidCell | null> {
|
||||
var validator = new Validator({ Value: rules });
|
||||
var cellValue = cellArgs.rowData[cellArgs.field];
|
||||
|
||||
var options: IValidateOptions = {
|
||||
suppressWarning: true,
|
||||
messages: {
|
||||
required: "Required",
|
||||
number: {
|
||||
min: "%s must be greater than or equal to %s",
|
||||
max: "%s must be less than or equal to %s"
|
||||
}
|
||||
},
|
||||
cellArgs: cellArgs
|
||||
};
|
||||
|
||||
try {
|
||||
await validator.validate({ Value: cellValue }, options);
|
||||
return null;
|
||||
}
|
||||
catch (errors: any) {
|
||||
return {
|
||||
rowKey: cellArgs.rowKey,
|
||||
cellKey: cellArgs.cellKey,
|
||||
field: cellArgs.field,
|
||||
value: cellValue,
|
||||
validationMessages: errors?.errors?.map((item: any) => item.message) ?? []
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
updateExistingInvalidCells(cellKey: string, invalidCell: IInvalidCell | null, existingInvalidCells: IInvalidCell[]) {
|
||||
if (invalidCell == null ) {
|
||||
// validation passed so make sure this cell is cleared from state invalid cells
|
||||
var invalidCellIndex = existingInvalidCells.findIndex(item => item.cellKey === cellKey);
|
||||
if (invalidCellIndex != -1) {
|
||||
var newInvalidCells = [...existingInvalidCells]; // make a copy since we have to modify it
|
||||
newInvalidCells.splice(invalidCellIndex, 1);
|
||||
return newInvalidCells;
|
||||
} else {
|
||||
return existingInvalidCells;
|
||||
}
|
||||
} else {
|
||||
var newInvalidCells = [...existingInvalidCells]; // make a copy since we always have to modify it
|
||||
var invalidCellIndex = newInvalidCells.findIndex(item => item.cellKey === invalidCell.cellKey);
|
||||
if (invalidCellIndex != -1) {
|
||||
newInvalidCells.splice(invalidCellIndex, 1);
|
||||
}
|
||||
newInvalidCells.push(invalidCell);
|
||||
return newInvalidCells;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
async validateData(data: any[], columns: IBaseColumnProps[], rowDataKeyField: string): Promise<IInvalidCell[]> {
|
||||
const invalidCells: IInvalidCell[] = [];
|
||||
|
||||
// can't use forEach because of nested await
|
||||
for (let r = 0; r < data.length; r++) {
|
||||
const row = data[r];
|
||||
for (let c = 0; c < columns.length; c++) {
|
||||
const col = columns[c];
|
||||
if (col.validationRules != null && col.field != null) {
|
||||
var cellArgs = ColumnUtils.getCellArgs(rowDataKeyField, row, col);
|
||||
cellArgs.gridData = data;
|
||||
|
||||
const invalidCell = await DataGridValidator.validateCell(cellArgs, col.validationRules);
|
||||
if (invalidCell != null) {
|
||||
invalidCells.push(invalidCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return invalidCells;
|
||||
}
|
||||
}
|
||||
|
||||
export default DataGridValidator;
|
||||
@@ -0,0 +1,10 @@
|
||||
const DataRowClass = {
|
||||
Highlight: "p-datatable-row--highlight",
|
||||
ParentRow: "p-datatable-row--parent",
|
||||
ChildRow: "p-datatable-row--child",
|
||||
Disabled: "p-datatable-row--disabled",
|
||||
Bold: "p-datatable-row--bold",
|
||||
Borderless: "p-datatable-row--borderless"
|
||||
}
|
||||
|
||||
export default DataRowClass;
|
||||
@@ -0,0 +1,43 @@
|
||||
import TotalRowCalculator from "./TotalRowCalculator";
|
||||
|
||||
describe('TotalRowCalculator Tests', () => {
|
||||
const data = [
|
||||
{ prop1: 0, prop2: 1 },
|
||||
{ prop1: 1, prop2: 2 },
|
||||
{ prop1: null, prop2: 3 }
|
||||
]
|
||||
|
||||
test('getNonNullValues remove nulls', () => {
|
||||
var values = TotalRowCalculator.getNonNullValues(data, "prop1");
|
||||
expect(values.length).toBe(2);
|
||||
expect(values[0]).toBe(0);
|
||||
expect(values[1]).toBe(1);
|
||||
});
|
||||
|
||||
test('calculateTotalRow sums correctly', () => {
|
||||
var totalValue = TotalRowCalculator.calculateTotalRow(data, [
|
||||
{ field: "prop1", calculationType: "sum" },
|
||||
{ field: "prop2", calculationType: "sum" }
|
||||
]);
|
||||
expect(totalValue.prop1).toBe(1);
|
||||
expect(totalValue.prop2).toBe(6);
|
||||
});
|
||||
|
||||
test('calculateTotalRow avgs correctly', () => {
|
||||
var totalValue = TotalRowCalculator.calculateTotalRow(data, [
|
||||
{ field: "prop1", calculationType: "avg" },
|
||||
{ field: "prop2", calculationType: "avg" }
|
||||
]);
|
||||
expect(totalValue.prop1).toBe(1/2);
|
||||
expect(totalValue.prop2).toBe(6/3);
|
||||
});
|
||||
|
||||
test('calculateTotalRow custom function runs correctly', () => {
|
||||
var totalValue = TotalRowCalculator.calculateTotalRow(data, [
|
||||
{
|
||||
field: "prop2", calculationType: "custom", customCalculation: (values, allData) => Math.max(...values)
|
||||
}
|
||||
]);
|
||||
expect(totalValue.prop2).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ITotalRowCalculation } from "./DataGrid";
|
||||
import { sum } from "lodash";
|
||||
|
||||
const TotalRowCalculator = {
|
||||
// calculate the total row
|
||||
calculateTotalRow(data: any[], calculations: ITotalRowCalculation[]) {
|
||||
// create total row record with zero values for each field
|
||||
const totalRow = {} as any;
|
||||
calculations.forEach(calc => totalRow[calc.field] = 0);
|
||||
|
||||
// calculate the totals
|
||||
calculations.forEach(calc => {
|
||||
const values = TotalRowCalculator.getNonNullValues(data, calc.field);
|
||||
let total = 0;
|
||||
switch (calc.calculationType) {
|
||||
case "sum":
|
||||
total = sum(values);
|
||||
break;
|
||||
case "avg":
|
||||
total = sum(values) / values.length;
|
||||
break;
|
||||
case "custom":
|
||||
if (calc.customCalculation != null) {
|
||||
total = calc.customCalculation(values, data);
|
||||
}
|
||||
}
|
||||
totalRow[calc.field] = total;
|
||||
});
|
||||
|
||||
return totalRow;
|
||||
},
|
||||
|
||||
// retrieve all non null values for a field from an array of records. Faster version of map + filter
|
||||
getNonNullValues(data: any[], field: string): any[] {
|
||||
return data.reduce((result: any[], record) => {
|
||||
const val = record[field];
|
||||
if (val != null && val !== "") {
|
||||
result.push(val);
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default TotalRowCalculator;
|
||||
@@ -0,0 +1,14 @@
|
||||
import DataGrid, {
|
||||
IDataGridBaseProps, IDataGridProps, ITotalRowCalculation,
|
||||
IInvalidCell, IDataGridSelectionEvent, IDataGridPageEvent, IDataGridSortEvent, IGlobalFilterValue,
|
||||
IDataGridFilterEvent, IDataGridTotalCellMetaData
|
||||
} from "./DataGrid";
|
||||
import { IValidateOptions } from "./DataGridValidator";
|
||||
import DataRowClass from "./DataRowClass";
|
||||
|
||||
export default DataGrid;
|
||||
export {
|
||||
IDataGridBaseProps, IDataGridProps, ITotalRowCalculation, IInvalidCell, DataRowClass, IValidateOptions,
|
||||
IDataGridSelectionEvent, IDataGridPageEvent, IDataGridSortEvent, IGlobalFilterValue,
|
||||
IDataGridFilterEvent, IDataGridTotalCellMetaData
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import { IBaseColumnProps } from "../datacolumn/IBaseColumnProps";
|
||||
|
||||
export interface IDataHeaderCellProps extends Partial<IBaseColumnProps> {
|
||||
/** Number of rows to span */
|
||||
rowSpan?: number;
|
||||
|
||||
/** Number of columns to span */
|
||||
colSpan?: number;
|
||||
|
||||
/** Make this a gap column for spacing */
|
||||
isGap?: boolean;
|
||||
|
||||
/** Make this an expandable column */
|
||||
isExpandable?: boolean;
|
||||
|
||||
/** Whether columns are expanded or not */
|
||||
expanded?: boolean;
|
||||
|
||||
/** Called when expand icon is clicked */
|
||||
onExpand?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const DataHeaderCell: React.FC<IDataHeaderCellProps> = (props: IDataHeaderCellProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The header cell component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export default DataHeaderCell;
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface IDataHeaderGroupProps {
|
||||
/** List of DataHeaderRows */
|
||||
children?: React.ReactElement | React.ReactElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const DataHeaderGroup: React.FC<IDataHeaderGroupProps> = (props: IDataHeaderGroupProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The header row component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export default DataHeaderGroup;
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface IDataHeaderRowProps {
|
||||
/** List of DataHeaderCells */
|
||||
children?: React.ReactElement | React.ReactElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const DataHeaderRow: React.FC<IDataHeaderRowProps> = (props: IDataHeaderRowProps) => {
|
||||
// Note this class is only for intellisense with typescript.
|
||||
// The header row component for a datagrid is never rendered. It is only used to propagate props.
|
||||
// Component lifecycle and constructor will never be called.
|
||||
// Don't put any logic here. It will never be called.
|
||||
|
||||
// In case you didn't read above, DO NOT PUT ANY LOGIC HERE.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export default DataHeaderRow;
|
||||
@@ -0,0 +1,208 @@
|
||||
import * as React from "react";
|
||||
import { DataHeaderRow, DataHeaderCell } from "../dataheader";
|
||||
import { IBaseColumnProps, INumberColumnProps } from "../datacolumn";
|
||||
import { ColumnGroup } from "primereact/columngroup";
|
||||
import { formatNumber } from "@strata/intl/lib";
|
||||
import { IDataHeaderCellProps } from "./DataHeaderCell";
|
||||
import { RowNumberClassName } from "../datacolumn/RowNumberColumn";
|
||||
import Link from "../link/Link";
|
||||
import Button from '../button/Button';
|
||||
import { IDataGridTotalCellMetaData } from "../datagrid/DataGrid";
|
||||
import MinusSquareIcon from "../icon/MinusSquareIcon";
|
||||
import PlusSquareIcon from "../icon/PlusSquareIcon";
|
||||
|
||||
export interface ITotalLabelConfig {
|
||||
totalRowLabel?: string,
|
||||
totalRowHref?: string,
|
||||
totalRowOnClick?: React.MouseEventHandler<HTMLElement>
|
||||
}
|
||||
|
||||
export default class DataHeaderUtils {
|
||||
static getHeaderColumnGroups(totalRowData: any, totalLabelConfig: ITotalLabelConfig, columnHeaderGroup: React.ReactElement | undefined, columns: React.ReactElement[], hasRowNumber: boolean, totalRowCellsMetaData: IDataGridTotalCellMetaData[] | undefined): React.ReactElement | null {
|
||||
let columnHeaders: React.ReactElement[] = [];
|
||||
// columns are required
|
||||
if (columns == null || columns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get column props
|
||||
const columnProps = columns.map((column) => column.props).filter(prop => !prop.frozen);
|
||||
const hasFrozenColumns = columnProps.length !== columns.length;
|
||||
|
||||
// add custom column header rows if defined
|
||||
if (columnHeaderGroup) {
|
||||
columnHeaders.push(...DataHeaderUtils.transformDataHeaderRows(columnHeaderGroup, hasFrozenColumns ? false : hasRowNumber));
|
||||
}
|
||||
|
||||
// add total row if defined
|
||||
if (totalRowData != null) {
|
||||
// if no custom column headers defined, we need to manually create them
|
||||
if (columnHeaders.length == 0) {
|
||||
columnHeaders.push(this.getDefaultHeaderRow(columnProps));
|
||||
}
|
||||
// if we have frozen columns, don't create total label since that will be created by frozen column group
|
||||
columnHeaders.push(this.getTotalRow(totalRowData, columnProps, hasFrozenColumns ? undefined : totalLabelConfig, totalRowCellsMetaData));
|
||||
}
|
||||
|
||||
if (columnHeaders.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
return <ColumnGroup>{columnHeaders.map(item => item)}</ColumnGroup>;
|
||||
}
|
||||
}
|
||||
|
||||
static getFrozenHeaderColumnGroups(totalRowData: any, totalLabelConfig: ITotalLabelConfig, frozenColumnHeaderGroup: React.ReactElement | undefined, columns: React.ReactElement[], hasRowNumber: boolean, totalRowCellsMetaData: IDataGridTotalCellMetaData[] | undefined): React.ReactElement | null {
|
||||
let columnHeaders: React.ReactElement[] = [];
|
||||
|
||||
// columns are required
|
||||
if (columns == null || columns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get column props
|
||||
const columnProps = columns.map((column) => column.props).filter(prop => prop.frozen);
|
||||
|
||||
// add custom column header rows if defined
|
||||
if (frozenColumnHeaderGroup) {
|
||||
columnHeaders.push(...DataHeaderUtils.transformDataHeaderRows(frozenColumnHeaderGroup, hasRowNumber));
|
||||
}
|
||||
|
||||
// add total row if defined
|
||||
if (totalRowData != null) {
|
||||
// if no custom column headers defined, we need to manually create them
|
||||
if (columnHeaders.length == 0) {
|
||||
columnHeaders.push(this.getDefaultHeaderRow(columnProps));
|
||||
}
|
||||
// if we have frozen columns, don't create total label since that will be created by frozen column group
|
||||
columnHeaders.push(this.getTotalRow(totalRowData, columnProps, totalLabelConfig, totalRowCellsMetaData));
|
||||
}
|
||||
|
||||
if (columnHeaders.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
return <ColumnGroup>{columnHeaders.map(item => item)}</ColumnGroup>;
|
||||
}
|
||||
}
|
||||
|
||||
private static getTotalRow(totalRowData: any, columnProps: IBaseColumnProps[], totalLabelConfig?: ITotalLabelConfig, totalRowCellsMetaData?: IDataGridTotalCellMetaData[]): React.ReactElement {
|
||||
let cells: React.ReactElement[] = [];
|
||||
let firstTotalCellIndex = -1;
|
||||
// add a cell for every column and populate total value if found
|
||||
for (let index = 0; index < columnProps.length; index++) {
|
||||
const column = columnProps[index];
|
||||
let header: string = "";
|
||||
let totalCellClassName = "p-total-cell"
|
||||
if (column.field != null) {
|
||||
const total = totalRowData[column.field];
|
||||
if (total != null) {
|
||||
const format = (column as INumberColumnProps).format || "number";
|
||||
const formatOptions = (column as INumberColumnProps).formatOptions || {};
|
||||
header = formatNumber(total, format, formatOptions);
|
||||
if (firstTotalCellIndex === -1) {
|
||||
firstTotalCellIndex = index;
|
||||
}
|
||||
}
|
||||
if (totalRowCellsMetaData && totalRowCellsMetaData.length > 0) {
|
||||
const totalRowMetaData = totalRowCellsMetaData?.find((metaData) => metaData.field === column.field);
|
||||
if (totalRowMetaData) {
|
||||
const { textColor } = totalRowMetaData;
|
||||
switch (textColor) {
|
||||
case "success":
|
||||
totalCellClassName += " p-total-cell--success"
|
||||
break;
|
||||
case "error":
|
||||
totalCellClassName += " p-total-cell--error"
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.push(<DataHeaderCell key={index} header={header} className={totalCellClassName} />);
|
||||
}
|
||||
|
||||
if (totalLabelConfig && (cells.length > 0)) {
|
||||
const { totalRowLabel = "Total", totalRowHref, totalRowOnClick } = totalLabelConfig;
|
||||
|
||||
let header = totalRowHref ? <Link href={totalRowHref}>{totalRowLabel}</Link> : totalRowOnClick ? <Button type="link" onClick={totalRowOnClick}>{totalRowLabel}</Button> : totalRowLabel;
|
||||
let headerCell = <DataHeaderCell key={-1} colSpan={firstTotalCellIndex > -1 ? firstTotalCellIndex : cells.length} header={header} className="p-total-cell p-total-cell--total-label" />;
|
||||
|
||||
if (firstTotalCellIndex > -1) {
|
||||
// merge cells at the beginning that doesn't have a total value
|
||||
cells = cells.slice(firstTotalCellIndex);
|
||||
cells.splice(0, 0, headerCell);
|
||||
} else {
|
||||
// if no total cells, we will insert just the header label that spans the entire row
|
||||
cells = [headerCell];
|
||||
}
|
||||
}
|
||||
|
||||
return (<DataHeaderRow key="totalRow">{cells.map(c => c)}</DataHeaderRow>);
|
||||
}
|
||||
|
||||
private static getDefaultHeaderRow(columnProps: IBaseColumnProps[]): React.ReactElement {
|
||||
let cells: React.ReactElement[] = [];
|
||||
// add a cell for every column
|
||||
for (let index = 0; index < columnProps.length; index++) {
|
||||
const column = columnProps[index];
|
||||
cells.push(<DataHeaderCell key={index} {...column} />);
|
||||
}
|
||||
return (<DataHeaderRow key="defaultHeader">{cells.map(c => c)}</DataHeaderRow>);
|
||||
}
|
||||
|
||||
private static transformDataHeaderRows(columnHeaderGroup: React.ReactElement, hasRowNumber: boolean): React.ReactElement[] {
|
||||
if (columnHeaderGroup == null) { return []; }
|
||||
|
||||
const dataRowHeaders = columnHeaderGroup.props.children;
|
||||
return React.Children.map(dataRowHeaders, (headerRow: React.ReactElement, index: number) => {
|
||||
const validProps = { ...headerRow.props };
|
||||
|
||||
if (validProps.children) {
|
||||
const isLastRow = index === dataRowHeaders.length - 1;
|
||||
const validChildren = React.Children.map(validProps.children, (headerCell: React.ReactElement) => {
|
||||
return React.cloneElement(headerCell,
|
||||
DataHeaderUtils.transformDataHeaderCellsProps(headerCell.props as IDataHeaderCellProps, isLastRow));
|
||||
});
|
||||
|
||||
// insert special row number column
|
||||
if (hasRowNumber && index === 0) {
|
||||
validChildren.splice(0, 0, <DataHeaderCell className={RowNumberClassName} rowSpan={dataRowHeaders.length} />);
|
||||
}
|
||||
|
||||
validProps.children = validChildren;
|
||||
}
|
||||
|
||||
return React.cloneElement(headerRow, validProps);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private static transformDataHeaderCellsProps(props: IDataHeaderCellProps, isLastRow: boolean): any {
|
||||
let { align = "left", width, isGap, isExpandable, expanded, onExpand, ...validProps } = props;
|
||||
|
||||
if (validProps.filter) {
|
||||
validProps.filterMatchMode = validProps.filterMatchMode || "contains";
|
||||
}
|
||||
|
||||
if (isExpandable) {
|
||||
validProps.header = <span className="p-column-expand-icon" onClick={() => onExpand && onExpand(!expanded)}>
|
||||
{expanded ? (<MinusSquareIcon color="blue-700" size={14} />) : (<PlusSquareIcon color="blue-700" size={14} />)}
|
||||
</span>;
|
||||
}
|
||||
|
||||
validProps.style = Object.assign(validProps.style || {}, {
|
||||
textAlign: align,
|
||||
width: width
|
||||
});
|
||||
|
||||
validProps.className = ("p-column-group-cell"
|
||||
+ (isLastRow ? " p-column-group-cell--last-row" : "") // last row should have bold styling
|
||||
+ ((!isLastRow && props.header) ? " p-column-group-cell--header" : "") // header cells should have bottom border
|
||||
+ (isGap === true ? " p-column-group-cell--gap" : "")
|
||||
+ (isExpandable === true ? " p-column-group-cell--expandable" : "")
|
||||
+ (" " + (validProps.className || ""))).trim();
|
||||
|
||||
return validProps;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import DataHeaderCell, { IDataHeaderCellProps } from "./DataHeaderCell";
|
||||
import DataHeaderRow, { IDataHeaderRowProps } from "./DataHeaderRow";
|
||||
import DataHeaderGroup, { IDataHeaderGroupProps } from "./DataHeaderGroup";
|
||||
import DataHeaderUtils from "./DataHeaderUtils";
|
||||
|
||||
export { DataHeaderGroup, IDataHeaderGroupProps, DataHeaderCell, IDataHeaderCellProps, DataHeaderRow, IDataHeaderRowProps, DataHeaderUtils };
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import DatePicker from './DatePicker';
|
||||
import dayjs from 'dayjs';
|
||||
import intlService from '@strata/intl/lib';
|
||||
|
||||
test('verify date can be selected', () => {
|
||||
render(<>
|
||||
<DatePicker defaultValue={dayjs(new Date(2022, 1, 2))}></DatePicker>
|
||||
</>);
|
||||
|
||||
fireEvent.click(screen.getByDisplayValue('Feb 2, 2022'));
|
||||
fireEvent.click(screen.getByText('15'));
|
||||
|
||||
screen.getByDisplayValue('Feb 15, 2022');
|
||||
|
||||
});
|
||||
|
||||
test('verify L format', () => {
|
||||
render(<>
|
||||
<DatePicker defaultValue={dayjs(new Date(2022, 2, 2))} format='L'></DatePicker>
|
||||
</>);
|
||||
|
||||
fireEvent.click(screen.getByDisplayValue('03/02/2022'));
|
||||
fireEvent.click(screen.getByText('15'));
|
||||
|
||||
screen.getByDisplayValue('03/15/2022');
|
||||
|
||||
});
|
||||
|
||||
test('verify en-GB format', () => {
|
||||
intlService.changeCulture('en-GB');
|
||||
|
||||
render(<>
|
||||
<DatePicker defaultValue={dayjs(new Date(2022, 1, 2))}></DatePicker>
|
||||
</>);
|
||||
|
||||
fireEvent.click(screen.getByDisplayValue('2 Feb 2022'));
|
||||
fireEvent.click(screen.getByText('15'));
|
||||
|
||||
screen.getByDisplayValue('15 Feb 2022');
|
||||
|
||||
intlService.changeCulture('en-US');
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as React from "react";
|
||||
import { DatePicker as AntDatePicker } from "antd";
|
||||
import MonthPicker, { IMonthPickerProps } from "./MonthPicker";
|
||||
import RangePicker, { IRangePickerProps } from './RangePicker';
|
||||
import TimePicker, { ITimePickerProps } from './TimePicker';
|
||||
import * as dayjs from 'dayjs';
|
||||
import intlService, { DateTimeFormat } from '@strata/intl/lib';
|
||||
import enUSLocale from 'antd/lib/date-picker/locale/en_US';
|
||||
import enGBIELocale from 'antd/lib/date-picker/locale/en_GB';
|
||||
import YearPicker, { IYearPickerProps } from "./YearPicker";
|
||||
import { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
|
||||
export interface IDatePickerProps {
|
||||
/** Allow deselecting */
|
||||
allowClear?: boolean;
|
||||
|
||||
/** Get focus and open the date picker on load */
|
||||
autoFocus?: boolean;
|
||||
|
||||
/** Disable item */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Specify dates that cannot be selected */
|
||||
disabledDate?: (current: dayjs.Dayjs | null) => boolean;
|
||||
|
||||
/** Displayed in the input box and the selected date when the picker is opened */
|
||||
defaultValue?: dayjs.Dayjs;
|
||||
|
||||
/** The selected date when the picker is opened. Overridden by defaultValue */
|
||||
defaultPickerValue?: dayjs.Dayjs;
|
||||
|
||||
/** Format string for date. L for short date, ll for long date string. Defaults to ll. */
|
||||
format?: "L" | "ll";
|
||||
|
||||
/** Input placeholder */
|
||||
placeholder?: string;
|
||||
|
||||
/** Selected date */
|
||||
value?: dayjs.Dayjs;
|
||||
|
||||
/** Called on date change */
|
||||
onChange?: (date: dayjs.Dayjs | null, dateString: string | string[]) => void;
|
||||
|
||||
/** Called when input gains focus */
|
||||
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
||||
|
||||
/** Input box size. Default is "normal" */
|
||||
size?: "dense" | "normal" | "large";
|
||||
|
||||
/** Input width. Default is "100%"" */
|
||||
width?: number | string;
|
||||
|
||||
/** Show the "Today" button. Default is true */
|
||||
showToday?: boolean;
|
||||
|
||||
/** Used to remove the border. Defaults to true */
|
||||
bordered?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datepicker allows users to select a date or date range from a calendar by day, month, year or time.
|
||||
* @beta
|
||||
*/
|
||||
const DatePicker: React.FC<IDatePickerProps> & { MonthPicker: React.FC<IMonthPickerProps> } & { RangePicker: React.FC<IRangePickerProps> } & { YearPicker: React.FC<IYearPickerProps> } & { TimePicker: React.FC<ITimePickerProps> } = (props: IDatePickerProps) => {
|
||||
const { width = "100%", placeholder = "", autoFocus = false, format = "ll", size = "normal", onFocus, ...validProps } = props;
|
||||
const style = { width };
|
||||
|
||||
//get the locale from strata/intl service and use that to configure datepicker formatting
|
||||
const language = (intlService.getCulture() || 'en-US');
|
||||
const locale = language === 'en-GB' ? enGBIELocale : language === 'en-IE' ? enGBIELocale : enUSLocale;
|
||||
|
||||
// need to get actual format string because ant doesn't understand ll and L
|
||||
const dateFormat: DateTimeFormat = (format === 'L') ? 'date' : 'dateLong';
|
||||
const localizedFormatString = intlService.getDateFormatString(dateFormat);
|
||||
|
||||
// default open when auto focus is set
|
||||
Object.assign(validProps, {
|
||||
autoFocus: autoFocus,
|
||||
defaultOpen: autoFocus // internal rc-datepicker prop
|
||||
});
|
||||
|
||||
let sizeType: SizeType = 'middle';
|
||||
if (size != "normal") {
|
||||
sizeType = size === 'dense' ? "small" : "large";
|
||||
}
|
||||
|
||||
return (
|
||||
<AntDatePicker
|
||||
{...validProps}
|
||||
onFocus={(e) => onFocus && onFocus(e as React.FocusEvent<HTMLInputElement>)}
|
||||
format={localizedFormatString}
|
||||
placeholder={placeholder}
|
||||
style={style}
|
||||
size={sizeType}
|
||||
locale={locale}>
|
||||
</AntDatePicker>
|
||||
);
|
||||
};
|
||||
|
||||
DatePicker.MonthPicker = MonthPicker;
|
||||
DatePicker.YearPicker = YearPicker;
|
||||
DatePicker.RangePicker = RangePicker;
|
||||
DatePicker.TimePicker = TimePicker;
|
||||
|
||||
export default DatePicker;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import MonthPicker from './MonthPicker';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
test('verify month can be selected', () => {
|
||||
render(<>
|
||||
<MonthPicker defaultValue={dayjs(new Date(2022, 1, 2))} ></MonthPicker>
|
||||
</>);
|
||||
|
||||
fireEvent.click(screen.getByDisplayValue('Feb 2022'));
|
||||
fireEvent.click(screen.getByText('May'));
|
||||
|
||||
screen.getByDisplayValue('May 2022');
|
||||
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { DatePicker as AntDatePicker } from "antd";
|
||||
import * as dayjs from 'dayjs';
|
||||
import { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
|
||||
export interface IMonthPickerProps {
|
||||
/** Allow deselecting */
|
||||
allowClear?: boolean;
|
||||
|
||||
/** Disable item */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Specify dates that cannot be selected */
|
||||
disabledDate?: (current: dayjs.Dayjs | null) => boolean;
|
||||
|
||||
/** Displayed in the input box and the selected date when the picker is opened */
|
||||
defaultValue?: dayjs.Dayjs;
|
||||
|
||||
/** The selected date when the picker is opened. Overridden by defaultValue */
|
||||
defaultPickerValue?: dayjs.Dayjs;
|
||||
|
||||
/** Input placeholder */
|
||||
placeholder?: string;
|
||||
|
||||
/** Selected month */
|
||||
value?: dayjs.Dayjs;
|
||||
|
||||
/** Called on month change */
|
||||
onChange?: (date: dayjs.Dayjs | null, dateString: string | string[]) => void;
|
||||
|
||||
/** Input width. Default is "100%" */
|
||||
width?: number | string;
|
||||
|
||||
/** Used to remove the border. Defaults to true */
|
||||
bordered?: boolean;
|
||||
|
||||
/** Format of the selected month. Defaults to MMM YYYY */
|
||||
format?: string;
|
||||
|
||||
/** Default is normal */
|
||||
size?: "dense" | "normal";
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const MonthPicker: React.FC<IMonthPickerProps> = (props: IMonthPickerProps) => {
|
||||
const { width = "100%", placeholder = "", format = "MMM YYYY", size, ...validProps } = props;
|
||||
const style = { width };
|
||||
|
||||
let sizeType: SizeType = 'middle';
|
||||
if (size && size == "dense") {
|
||||
sizeType = 'small';
|
||||
}
|
||||
|
||||
return (
|
||||
<AntDatePicker.MonthPicker {...validProps} changeOnBlur format={format} placeholder={placeholder} style={style} size={sizeType}>
|
||||
</AntDatePicker.MonthPicker>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonthPicker;
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from "react";
|
||||
import { DatePicker as AntDatePicker } from "antd";
|
||||
import * as dayjs from 'dayjs';
|
||||
import intlService, { DateTimeFormat } from '@strata/intl/lib';
|
||||
import enUSLocale from 'antd/lib/date-picker/locale/en_US';
|
||||
import enGBIELocale from 'antd/lib/date-picker/locale/en_GB';
|
||||
import ArrowRightOutlined from "@ant-design/icons/ArrowRightOutlined";
|
||||
import { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
import { RangeValueType } from 'rc-picker/lib/PickerInput/RangePicker';
|
||||
|
||||
export type RangeValue = RangeValueType<dayjs.Dayjs>;
|
||||
|
||||
export interface IRangePickerProps {
|
||||
/** Allow deselecting */
|
||||
allowClear?: boolean;
|
||||
|
||||
/** Allow start or end input leave empty */
|
||||
allowEmpty?: [boolean, boolean];
|
||||
|
||||
/** Get focus and open the date picker on load */
|
||||
autoFocus?: boolean;
|
||||
|
||||
/** Disable item */
|
||||
disabled?: [boolean, boolean];
|
||||
|
||||
/** Specify dates that cannot be selected */
|
||||
disabledDate?: (current: dayjs.Dayjs | null) => boolean;
|
||||
|
||||
/** Displayed in the input box and the selected date when the picker is opened */
|
||||
defaultValue?: RangeValue;
|
||||
|
||||
/** The selected date when the picker is opened. Overridden by defaultValue */
|
||||
defaultPickerValue?: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
|
||||
/** Format string for date. L for short date, ll for long date string. Defaults to ll for date picker. */
|
||||
format?: "L" | "ll" | string;
|
||||
|
||||
/** Input placeholder */
|
||||
placeholder?: [string, string];
|
||||
|
||||
/** Selected date */
|
||||
value?: RangeValue;
|
||||
|
||||
/** Preset ranges for quick selection */
|
||||
ranges?: Record<string, [dayjs.Dayjs, dayjs.Dayjs] | (() => [dayjs.Dayjs, dayjs.Dayjs])>;
|
||||
|
||||
/** Called on date change */
|
||||
onChange?: (values: RangeValue, formatString: [string, string]) => void;
|
||||
|
||||
/** Called when input gains focus */
|
||||
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
||||
|
||||
/** Input width. Default is "100%"" */
|
||||
width?: number | string;
|
||||
|
||||
/** Used to remove the border. Defaults to true */
|
||||
bordered?: boolean;
|
||||
|
||||
/** To determine the size of the input box. Defaults to normal */
|
||||
size?: "dense" | "normal" | "large";
|
||||
|
||||
/** Picker type. Defaults to date */
|
||||
picker?: "date" | "week" | "month" | "quarter" | "year";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const RangePicker: React.FC<IRangePickerProps> = (props: IRangePickerProps) => {
|
||||
let { width = "100%", placeholder = ["", ""], autoFocus = false, format, picker = 'date', allowClear = false, size = "normal", onFocus, onChange, ...validProps } = props;
|
||||
const style = { width };
|
||||
|
||||
//get the locale from strata/intl service and use that to configure datepicker formatting
|
||||
const language = (intlService.getCulture() || 'en-US');
|
||||
const locale = language === 'en-GB' ? enGBIELocale : language === 'en-IE' ? enGBIELocale : enUSLocale;
|
||||
|
||||
if (picker === 'date') {
|
||||
// need to get actual format string because ant doesn't understand ll and L
|
||||
if (format === 'll' || format === 'L' || format == null) {
|
||||
const dateFormat: DateTimeFormat = (format === 'L') ? 'date' : 'dateLong';
|
||||
format = intlService.getDateFormatString(dateFormat);
|
||||
}
|
||||
}
|
||||
if (picker === 'month' && format == null) {
|
||||
format = 'MM/YYYY';
|
||||
}
|
||||
|
||||
// default open when auto focus is set
|
||||
Object.assign(validProps, {
|
||||
autoFocus: autoFocus,
|
||||
allowClear: allowClear,
|
||||
defaultOpen: autoFocus // internal rc-datepicker prop
|
||||
});
|
||||
|
||||
let sizeType: SizeType = 'middle';
|
||||
if (size != "normal") {
|
||||
sizeType = size === 'dense' ? "small" : "large";
|
||||
}
|
||||
|
||||
return (
|
||||
<AntDatePicker.RangePicker
|
||||
{...validProps}
|
||||
onFocus={(e) => onFocus && onFocus(e as React.FocusEvent<HTMLInputElement>)}
|
||||
onChange={(dates, dateString) => onChange && onChange(dates as RangeValue, dateString)}
|
||||
picker={picker}
|
||||
format={format}
|
||||
placeholder={placeholder}
|
||||
style={style}
|
||||
separator={<ArrowRightOutlined></ArrowRightOutlined>}
|
||||
size={sizeType}
|
||||
locale={locale} >
|
||||
</AntDatePicker.RangePicker>
|
||||
);
|
||||
};
|
||||
|
||||
export default RangePicker;
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import { DatePicker as AntDatePicker } from "antd";
|
||||
import { IntRange } from 'rc-picker/lib/interface';
|
||||
import * as dayjs from 'dayjs';
|
||||
import { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
|
||||
export interface ITimePickerProps {
|
||||
/** Allow deselecting */
|
||||
allowClear?: boolean;
|
||||
|
||||
/** Get focus and open the time picker on load */
|
||||
autoFocus?: boolean;
|
||||
|
||||
/** Displayed in the input box and the selected time when the picker is opened */
|
||||
defaultValue?: dayjs.Dayjs;
|
||||
|
||||
/** Disable item */
|
||||
disabled?: boolean;
|
||||
|
||||
/**Interval between minute in picker */
|
||||
minuteStep?: IntRange<1, 59>;
|
||||
|
||||
/** Input placeholder */
|
||||
placeholder?: string;
|
||||
|
||||
/** Selected time */
|
||||
value?: dayjs.Dayjs;
|
||||
|
||||
/** Called on time change */
|
||||
onChange?: (time: dayjs.Dayjs | null, timeString: string | string[]) => void;
|
||||
|
||||
/** Input width. Default is "100%" */
|
||||
width?: number | string;
|
||||
|
||||
/** Used to remove the border. Defaults to true */
|
||||
bordered?: boolean;
|
||||
|
||||
/** Default is normal */
|
||||
size?: "dense" | "normal";
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const TimePicker: React.FC<ITimePickerProps> = (props: ITimePickerProps) => {
|
||||
const { width = "100%", placeholder = '', size = "normal", ...validProps } = props;
|
||||
const style = { width };
|
||||
|
||||
let sizeType: SizeType = 'middle';
|
||||
if (size && size == "dense") {
|
||||
sizeType = 'small';
|
||||
}
|
||||
|
||||
return (
|
||||
<AntDatePicker.TimePicker
|
||||
{...validProps}
|
||||
use12Hours
|
||||
format="h:mm A"
|
||||
showNow={false}
|
||||
placeholder={placeholder}
|
||||
size={sizeType}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimePicker;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import YearPicker from './YearPicker';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
test('verify year can be selected', () => {
|
||||
render(<>
|
||||
<YearPicker defaultValue={dayjs(new Date(2022, 1, 2))} ></YearPicker>
|
||||
</>);
|
||||
|
||||
fireEvent.click(screen.getByDisplayValue('2022'));
|
||||
fireEvent.click(screen.getByText('2023'));
|
||||
|
||||
screen.getByDisplayValue('2023');
|
||||
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import { DatePicker as AntDatePicker } from "antd";
|
||||
import * as dayjs from 'dayjs';
|
||||
import { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
|
||||
export interface IYearPickerProps {
|
||||
/** Allow deselecting */
|
||||
allowClear?: boolean;
|
||||
|
||||
/** Get focus and open the date picker on load */
|
||||
autoFocus?: boolean;
|
||||
|
||||
/** Disable item */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Specify dates that cannot be selected */
|
||||
disabledDate?: (current: dayjs.Dayjs | null) => boolean;
|
||||
|
||||
/** Displayed in the input box and the selected date when the picker is opened */
|
||||
defaultValue?: dayjs.Dayjs;
|
||||
|
||||
/** The selected date when the picker is opened. Overridden by defaultValue */
|
||||
defaultPickerValue?: dayjs.Dayjs;
|
||||
|
||||
/** Input placeholder */
|
||||
placeholder?: string;
|
||||
|
||||
/** Selected Year */
|
||||
value?: dayjs.Dayjs;
|
||||
|
||||
/** Called on Year change */
|
||||
onChange?: (date: dayjs.Dayjs | null, dateString: string | string[]) => void;
|
||||
|
||||
/** Input width. Default is "100%" */
|
||||
width?: number | string;
|
||||
|
||||
/** Used to remove the border. Defaults to true */
|
||||
bordered?: boolean;
|
||||
|
||||
/** Format of the selected year. Defaults to YYYY */
|
||||
format?: string;
|
||||
|
||||
/** Default is normal */
|
||||
size?: "dense" | "normal";
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
const YearPicker: React.FC<IYearPickerProps> = (props: IYearPickerProps) => {
|
||||
const { width = "100%", placeholder = "", format = "YYYY", size = "normal", ...validProps } = props;
|
||||
const style = { width };
|
||||
|
||||
let sizeType: SizeType = 'middle';
|
||||
if (size && size == "dense") {
|
||||
sizeType = 'small';
|
||||
}
|
||||
|
||||
return (
|
||||
<AntDatePicker.YearPicker {...validProps} changeOnBlur format={format} placeholder={placeholder} style={style} size={sizeType} />
|
||||
);
|
||||
};
|
||||
|
||||
export default YearPicker;
|
||||
@@ -0,0 +1,14 @@
|
||||
import DatePicker, { IDatePickerProps } from "./DatePicker";
|
||||
import MonthPicker, { IMonthPickerProps } from "./MonthPicker";
|
||||
import YearPicker, { IYearPickerProps } from "./YearPicker";
|
||||
import RangePicker, { IRangePickerProps, RangeValue } from "./RangePicker";
|
||||
import TimePicker, { ITimePickerProps } from "./TimePicker";
|
||||
|
||||
export default DatePicker;
|
||||
export {
|
||||
IDatePickerProps,
|
||||
MonthPicker, IMonthPickerProps,
|
||||
YearPicker, IYearPickerProps,
|
||||
RangePicker, IRangePickerProps, RangeValue,
|
||||
TimePicker, ITimePickerProps
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface IDividerProps {
|
||||
/** Default is line. Dots ignore all other props */
|
||||
type?: "line" | "dot";
|
||||
|
||||
/** Margin on either side of the divider. Default is 16px */
|
||||
margin?: 0 | 8 | 16 | 24 | 32;
|
||||
|
||||
/** Make the divider vertical */
|
||||
vertical?: boolean;
|
||||
|
||||
/** Extend the divider to the edge of its container. Default is true when horizontal, false when vertical */
|
||||
extended?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Divider creates separation between content vertically or horizontally
|
||||
*/
|
||||
const Divider: React.FC<IDividerProps> = (props: IDividerProps) => {
|
||||
const { type = "line", margin = 16, vertical = false, extended = vertical ? false : true } = props;
|
||||
let marginTopBottom, marginLeftRight;
|
||||
|
||||
let className = `tempo-divider`;
|
||||
|
||||
if (type === "dot") {
|
||||
className += ` tempo-divider--dot`;
|
||||
marginLeftRight = 12;
|
||||
} else {
|
||||
if (extended) {
|
||||
className += ` tempo-divider--extended`;
|
||||
}
|
||||
|
||||
if (vertical) {
|
||||
className += ` tempo-divider--vertical`;
|
||||
marginLeftRight = margin;
|
||||
} else {
|
||||
marginTopBottom = margin;
|
||||
}
|
||||
}
|
||||
|
||||
const style = {
|
||||
marginTop: marginTopBottom,
|
||||
marginRight: marginLeftRight,
|
||||
marginBottom: marginTopBottom,
|
||||
marginLeft: marginLeftRight
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={style}></div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default Divider;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Divider, { IDividerProps } from "./Divider";
|
||||
|
||||
export default Divider;
|
||||
export { IDividerProps };
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react";
|
||||
import { Drawer as AntDrawer } from "antd";
|
||||
import Spacing from "../spacing/Spacing";
|
||||
import Loader from "../loader/Loader";
|
||||
import CheckCircleIcon from "../icon/CheckCircleIcon";
|
||||
import WarningIcon from "../icon/WarningIcon";
|
||||
import Text from '../text/Text';
|
||||
import CloseIcon from "../icon/CloseIcon";
|
||||
import Button from "../button/Button";
|
||||
import WarningCircleIcon from "../icon/WarningCircleIcon";
|
||||
|
||||
export const DRAWER_FOOTER_HEIGHT = 50;
|
||||
export const DRAWER_BODY_PADDING = 24;
|
||||
|
||||
export interface IDrawerProps {
|
||||
/** Show close button in the top right of the drawer. Default is true */
|
||||
closable?: boolean;
|
||||
|
||||
/** Unmount the child components on close. Default is true */
|
||||
destroyOnClose?: boolean;
|
||||
|
||||
/** Return the mounted node for Drawer */
|
||||
getContainer?: string | HTMLElement | false;
|
||||
|
||||
/** Show mask behind the drawer. Clicking the mask closes the drawer. Default is true */
|
||||
mask?: boolean;
|
||||
|
||||
/** Drawer title */
|
||||
title?: React.ReactNode;
|
||||
|
||||
/** Drawer footer */
|
||||
footer?: React.ReactNode;
|
||||
|
||||
/** Is the drawer visible */
|
||||
visible?: boolean;
|
||||
|
||||
/** Remove header padding */
|
||||
removeHeaderPadding?: boolean;
|
||||
|
||||
/** Remove body padding */
|
||||
removeBodyPadding?: boolean;
|
||||
|
||||
/** Drawer width. Default is 600px */
|
||||
width?: number | string;
|
||||
|
||||
/** Called when a user clicks mask, close button, or Cancel button */
|
||||
onClose?: (e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||
|
||||
/** Add a success or error icon to the drawer title */
|
||||
type?: "success" | "error" | "attention";
|
||||
|
||||
/** Side the drawer appears from. Default is "right" */
|
||||
placement?: "left" | "right";
|
||||
|
||||
/** Show loading mask */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drawer is an overlay panel containing content which slides from the edge of the screen
|
||||
* @beta
|
||||
*/
|
||||
const Drawer: React.FC<IDrawerProps> = (props) => {
|
||||
const { removeBodyPadding, loading, removeHeaderPadding, visible, destroyOnClose = true, onClose,
|
||||
closable = true, mask = true, width = 600, type, ...validProps } = props;
|
||||
let className = "tempo-drawer";
|
||||
|
||||
if (removeHeaderPadding) className += " tempo-drawer--no-header-padding";
|
||||
if (removeBodyPadding) className += " tempo-drawer--no-body-padding";
|
||||
|
||||
let drawerTitle = <Text.Heading level={2}>{props.title}</Text.Heading>;
|
||||
|
||||
if (type) {
|
||||
const icon = type === "success" ? <CheckCircleIcon color="success" size={24} /> : type === "attention" ? <WarningCircleIcon color="attention" size={24}/> : <WarningIcon color="error" size={24} />;
|
||||
drawerTitle = (
|
||||
<Spacing vAlign="center" itemSpacing={12}>
|
||||
{icon}
|
||||
{drawerTitle}
|
||||
</Spacing>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AntDrawer {...validProps}
|
||||
open={visible}
|
||||
className={className}
|
||||
destroyOnHidden={destroyOnClose}
|
||||
closable={false} // hide default close button and add close icon to extra
|
||||
onClose={onClose}
|
||||
extra={closable ? <Button type='link' icon={<CloseIcon></CloseIcon>} onClick={(e) => onClose && onClose(e)}></Button> : null}
|
||||
mask={mask}
|
||||
width={width}
|
||||
title={drawerTitle}
|
||||
push={{ distance: 48 }}>
|
||||
<Loader loading={loading}>
|
||||
{props.children}
|
||||
</Loader>
|
||||
</AntDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default Drawer;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Drawer, { IDrawerProps, DRAWER_FOOTER_HEIGHT, DRAWER_BODY_PADDING } from "./Drawer"
|
||||
|
||||
export default Drawer;
|
||||
export { IDrawerProps, DRAWER_FOOTER_HEIGHT, DRAWER_BODY_PADDING }
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import DropDown from './DropDown';
|
||||
import { dropDownTestUtils } from '@strata/test-utils/lib';
|
||||
import Button from '../button/Button';
|
||||
import PlusIcon from '../icon/PlusIcon';
|
||||
|
||||
test('single select', async () => {
|
||||
let selectedOption = '';
|
||||
render(<>
|
||||
<DropDown width={200}
|
||||
onChange={(val, option) => selectedOption = option.text}
|
||||
defaultValue={1}
|
||||
items={[
|
||||
{ text: "Option A", value: 1 },
|
||||
{ text: "Option B", value: 2 },
|
||||
{ text: "Option C", value: 3 },
|
||||
{ text: "Option D", value: 4 }
|
||||
]}
|
||||
/></>);
|
||||
|
||||
// get the first dropdown on the screen. you can also pass in name of dropdown to target specific one
|
||||
const dropdown = dropDownTestUtils.getDropDown();
|
||||
|
||||
// verify default value is set correctly
|
||||
expect(dropDownTestUtils.getSelectedOption(dropdown)).toEqual('Option A');
|
||||
|
||||
// change selected option
|
||||
await dropDownTestUtils.selectOption(dropdown, 'Option C');
|
||||
|
||||
// verify selected option is now Option C
|
||||
expect(selectedOption).toEqual('Option C');
|
||||
});
|
||||
|
||||
test('multi select', async () => {
|
||||
let selectedOption = '';
|
||||
render(<>
|
||||
<DropDown width={200}
|
||||
multiSelect
|
||||
onChange={(val, options) => selectedOption = options.map((o: { text: string, value: number }) => o.text).join(", ")}
|
||||
defaultValue={1}
|
||||
items={[
|
||||
{ text: "Option A", value: 1 },
|
||||
{ text: "Option B", value: 2 },
|
||||
{ text: "Option C", value: 3 },
|
||||
{ text: "Option D", value: 4 }
|
||||
]}
|
||||
/></>);
|
||||
|
||||
// get the first dropdown on the screen. you can also pass in name of dropdown to target specific one
|
||||
const dropdown = dropDownTestUtils.getDropDown();
|
||||
|
||||
// verify default value is set correctly
|
||||
expect(dropDownTestUtils.getSelectedOption(dropdown)).toEqual('Option A');
|
||||
|
||||
// add selected option
|
||||
await dropDownTestUtils.selectOption(dropdown, 'Option C');
|
||||
|
||||
// verify selected option is now Option A and Option C
|
||||
expect(selectedOption).toEqual('Option A, Option C');
|
||||
});
|
||||
|
||||
test('selectAllText and selectAllValue renders option correctly', async () => {
|
||||
let selectedOption = 0;
|
||||
render(<>
|
||||
<DropDown width={200}
|
||||
selectAllText="All Options"
|
||||
selectAllValue={-1}
|
||||
onChange={(val, option) => selectedOption = val as number}
|
||||
items={[
|
||||
{ text: "Option A", value: 1 },
|
||||
{ text: "Option B", value: 2 },
|
||||
{ text: "Option C", value: 3 },
|
||||
{ text: "Option D", value: 4 }
|
||||
]}
|
||||
/></>);
|
||||
|
||||
// get the first dropdown on the screen. you can also pass in name of dropdown to target specific one
|
||||
const dropdown = dropDownTestUtils.getDropDown();
|
||||
|
||||
// change selected option
|
||||
await dropDownTestUtils.selectOption(dropdown, 'All Options');
|
||||
|
||||
// verify selected option is All Options with -1 value
|
||||
expect(selectedOption).toEqual(-1);
|
||||
|
||||
})
|
||||
|
||||
test('extra renders link correctly', async () => {
|
||||
let onAddItemCalled = false;
|
||||
render(<>
|
||||
<DropDown width={200}
|
||||
extra={<Button icon={<PlusIcon />} onClick={() => onAddItemCalled = true}>Manage Items</Button>}
|
||||
items={[
|
||||
{ text: "Option A", value: 1 },
|
||||
{ text: "Option B", value: 2 },
|
||||
{ text: "Option C", value: 3 },
|
||||
{ text: "Option D", value: 4 }
|
||||
]}
|
||||
/></>);
|
||||
|
||||
// get the first dropdown on the screen. you can also pass in name of dropdown to target specific one
|
||||
const dropdown = dropDownTestUtils.getDropDown();
|
||||
|
||||
// show dropdown list
|
||||
fireEvent.mouseDown(dropdown);
|
||||
|
||||
// click Add Item
|
||||
const addItem = await screen.findByRole('button', { name: /Manage Items$/ });
|
||||
fireEvent.click(addItem);
|
||||
|
||||
// verify button clicked
|
||||
expect(onAddItemCalled).toEqual(true);
|
||||
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
import * as React from "react";
|
||||
import { Select } from 'antd';
|
||||
import Button from '../button';
|
||||
import { logger } from "@strata/logging/lib";
|
||||
import isArray from "lodash/isArray";
|
||||
import uniq from "lodash/uniq";
|
||||
import { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
|
||||
export type DropDownValue = string | string[] | number | number[];
|
||||
export interface IDropDownProps {
|
||||
/** Allow deselecting. Only use when needed (e.g. in an optional form field). Not available with selectAllText */
|
||||
allowClear?: boolean;
|
||||
|
||||
/** Get focus and open the drop-down on load */
|
||||
autoFocus?: boolean;
|
||||
|
||||
/** Default value */
|
||||
defaultValue?: DropDownValue;
|
||||
|
||||
/** Disable item */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Alternative way to populate the dropdown. Use with ItemTextField and ItemValueField */
|
||||
items?: any[];
|
||||
|
||||
/** Field for an item's text. Requires items to be set. Default is "text" */
|
||||
itemTextField?: string;
|
||||
|
||||
/** Field for an item's value. Requires items to be set. Default is "value" */
|
||||
itemValueField?: string;
|
||||
|
||||
/** Field for an item's group label. Requires items to be set. Default is "group" */
|
||||
itemGroupField?: string;
|
||||
|
||||
/** Allow selecting multiple items. Always true in useTags */
|
||||
multiSelect?: boolean;
|
||||
|
||||
/** Placeholder text */
|
||||
placeholder?: string | React.ReactNode;
|
||||
|
||||
/** Selected values */
|
||||
value?: DropDownValue;
|
||||
|
||||
/** Add an option for selecting all items. Customize the value with selectAllValue. Not available in multiSelect */
|
||||
selectAllText?: string;
|
||||
|
||||
/** Requires selectAllText to be set. Default is "" */
|
||||
selectAllValue?: string | number;
|
||||
|
||||
/** Drop-down width. Default is "100%" */
|
||||
width?: number | string;
|
||||
|
||||
/** Called on selection or search value change */
|
||||
onChange?: (value: DropDownValue, option: any) => void;
|
||||
|
||||
/** Show search on single select. Always true in multiSelect */
|
||||
showSearch?: boolean;
|
||||
|
||||
/** Called on search value change */
|
||||
onSearch?: (value: string) => void;
|
||||
|
||||
/** Placeholder text when there is no data. Must be used with onSearch */
|
||||
notFoundContent?: string;
|
||||
|
||||
/** Extra content at the end of drop down list */
|
||||
extra?: React.ReactNode;
|
||||
|
||||
/** Additional log data */
|
||||
logData?: object;
|
||||
|
||||
/** Turn off logging. Default to false */
|
||||
disableLogging?: boolean;
|
||||
|
||||
/** Called when drop down opens or closes */
|
||||
onDropdownVisibleChange?: (open: boolean) => void;
|
||||
|
||||
/** Use tags instead of placeholders. Always multiselect.*/
|
||||
useTags?: boolean;
|
||||
|
||||
/** Max tag text length.*/
|
||||
maxTagTextLength?: number;
|
||||
|
||||
/** Max count of tags displayed. 'responsive' will automatically calculated maxTagCount based on tag size. 'responsive' is not recommend use in large form case since responsive calculation has a perf cost.*/
|
||||
maxTagCount?: number | 'responsive';
|
||||
|
||||
/** Placehold of tags not displayed. Use with maxTagCount*/
|
||||
maxTagPlaceholder?: React.ReactNode | ((omittedValues: any[]) => React.ReactNode);
|
||||
|
||||
/** Whether selected options are allowed to wrap when using tags. Defaults to false */
|
||||
wrap?: boolean;
|
||||
|
||||
/** Default is normal */
|
||||
size?: "dense" | "normal";
|
||||
|
||||
/** Separator used to tokenize */
|
||||
tokenSeparators?: string[];
|
||||
|
||||
/** Selected text. Default is "selected" */
|
||||
selectedText?: string;
|
||||
};
|
||||
|
||||
export interface IDropDownItemProps {
|
||||
/** Disable item */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Item key */
|
||||
value?: string | number;
|
||||
|
||||
/** Create item groups with selectable parents. For non-selectable parents use ItemGroup */
|
||||
hierarchy?: "parent" | "child";
|
||||
};
|
||||
|
||||
export interface IDropDownItemGroupProps {
|
||||
label?: React.ReactNode;
|
||||
};
|
||||
|
||||
//#region Helper Functions
|
||||
const transformChildren = (props: React.PropsWithChildren<IDropDownProps>): React.ReactNode[] => {
|
||||
const { items, children, itemTextField = "text", itemValueField = "value", itemGroupField = "group", selectAllText, selectAllValue = "" } = props;
|
||||
|
||||
// children transformation
|
||||
let validChildren: React.ReactNode[] = [];
|
||||
let numOptions = 0;
|
||||
|
||||
if (children) {
|
||||
validChildren = React.Children.map(children as any || [], (child: React.ReactElement) => {
|
||||
if (child == null) { return null; }
|
||||
let props = Object.assign({}, (child as any).props) as React.PropsWithChildren<IDropDownItemProps>;
|
||||
if (props.hierarchy) {
|
||||
(props as any).className = props.hierarchy === "parent" ? "tempo-select-dropdown-menu-item--parent" : "tempo-select-dropdown-menu-item--child";
|
||||
}
|
||||
|
||||
if (Array.isArray(props.children)) {
|
||||
numOptions += props.children.length; // labeled group
|
||||
} else {
|
||||
numOptions++; // selectable group or flat list using children
|
||||
}
|
||||
|
||||
return React.cloneElement(child, props);
|
||||
});
|
||||
}
|
||||
|
||||
if (items && items.length > 0) {
|
||||
// check if we need to create option groups
|
||||
const groups = uniq(items.filter(item => item[itemGroupField] != null).map(item => item[itemGroupField]));
|
||||
if (groups.length > 0) {
|
||||
validChildren = groups.map(group =>
|
||||
<Select.OptGroup label={group} key={group}>
|
||||
{items.filter(item => item[itemGroupField] == group).map(item => <Select.Option key={item[itemValueField]} value={item[itemValueField]} disabled={item["disabled"]}>{item[itemTextField]}</Select.Option>)}
|
||||
</Select.OptGroup>);
|
||||
} else {
|
||||
validChildren = items.map(item => <Select.Option key={item[itemValueField]} value={item[itemValueField]} disabled={item["disabled"]}>{item[itemTextField]}</Select.Option>);
|
||||
}
|
||||
|
||||
numOptions = items.length; // flat list using items
|
||||
}
|
||||
|
||||
// add select all dropdown options. does not work in multiSelect
|
||||
if (validChildren.length > 0 && selectAllText && !props.multiSelect) {
|
||||
validChildren.splice(0, 0, <Select.Option key={selectAllValue} value={selectAllValue}>{selectAllText}</Select.Option>);
|
||||
}
|
||||
return [validChildren, numOptions];
|
||||
}
|
||||
|
||||
const getExtraProps = (props: IDropDownProps) => {
|
||||
const { extra } = props;
|
||||
if (extra == null) { return null; }
|
||||
|
||||
return {
|
||||
popupRender: (menu: React.ReactNode) => {
|
||||
return <>
|
||||
{menu}
|
||||
<div className="tempo-select-dropdown-menu-item-extra-wrapper" onMouseDown={e => e.preventDefault()}>
|
||||
{extra}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
};
|
||||
}
|
||||
//#endregion
|
||||
|
||||
/**
|
||||
* Drop-downs allow users to select one or more items from a list of many options.
|
||||
*/
|
||||
const DropDown: React.FC<IDropDownProps> & { Item: React.FC<IDropDownItemProps>, ItemGroup: React.FC<IDropDownItemGroupProps> } = (props) => {
|
||||
const { width = "100%", items, selectedText = "selected", itemTextField = "text", itemValueField = "value", selectAllText, selectAllValue,
|
||||
autoFocus, allowClear, children, multiSelect, useTags, maxTagTextLength, maxTagCount, maxTagPlaceholder, onChange, logData, disableLogging = false, size = "normal", ...validProps } = props;
|
||||
const style = { width };
|
||||
|
||||
// don't allow clear when select all is active
|
||||
Object.assign(validProps, {
|
||||
allowClear: selectAllText ? false : allowClear
|
||||
});
|
||||
|
||||
// default open when auto focus is set
|
||||
Object.assign(validProps, {
|
||||
autoFocus: autoFocus,
|
||||
defaultOpen: autoFocus
|
||||
});
|
||||
|
||||
// only allow notFoundContent when onSearch is set
|
||||
Object.assign(validProps, {
|
||||
notFoundContent: (props.onSearch && props.notFoundContent) ? props.notFoundContent : "No results"
|
||||
});
|
||||
|
||||
// add item
|
||||
Object.assign(validProps, getExtraProps(props));
|
||||
|
||||
// children
|
||||
const [validChildren, numOptions] = transformChildren(props);
|
||||
|
||||
if (useTags) {
|
||||
Object.assign(validProps, {
|
||||
mode: "multiple",
|
||||
maxTagTextLength: maxTagTextLength,
|
||||
maxTagCount: maxTagCount,
|
||||
maxTagPlaceholder: maxTagPlaceholder
|
||||
});
|
||||
} else if (multiSelect) {
|
||||
const maxTagPlaceholder = (selectedValues: any[]) => {
|
||||
const numSelected = selectedValues.length;
|
||||
|
||||
if (numSelected === 1) {
|
||||
return selectedValues[0].label;
|
||||
} else if (numOptions === numSelected) {
|
||||
return `All ${numSelected} ${selectedText}`;
|
||||
} else {
|
||||
return `${numSelected} ${selectedText}`;
|
||||
}
|
||||
};
|
||||
|
||||
Object.assign(validProps, {
|
||||
maxTagCount: 0,
|
||||
maxTagPlaceholder: maxTagPlaceholder,
|
||||
mode: "multiple"
|
||||
});
|
||||
}
|
||||
|
||||
// override defaults
|
||||
Object.assign(validProps, {
|
||||
defaultActiveFirstOption: false,
|
||||
optionFilterProp: "children",
|
||||
showAction: "focus" // internal prop from rc-select
|
||||
});
|
||||
|
||||
const onChangeInternal = (value: DropDownValue, option: any) => {
|
||||
const isMultiSelect = isArray(value);
|
||||
const valueArray = (isMultiSelect ? value : [value]) as [any];
|
||||
const optionArray = (isMultiSelect ? option : [option]) as [any];
|
||||
|
||||
const nameCSV = optionArray.map(x => x?.children?.toString()).join("|");
|
||||
|
||||
!disableLogging && logger.log("dropdown change", nameCSV, logData);
|
||||
|
||||
if (onChange) {
|
||||
if (items && items.length > 0) {
|
||||
// send back original item instead of option React node if items is available
|
||||
const selectedItems = items.filter(x => valueArray.indexOf(x[itemValueField]) > -1);
|
||||
onChange(value, isMultiSelect ? selectedItems : selectedItems[0]);
|
||||
} else {
|
||||
onChange(value, option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var classNames = ['tempo-select'];
|
||||
if (!props.wrap) {
|
||||
classNames.push('tempo-select--nowrap');
|
||||
}
|
||||
|
||||
let sizeType: SizeType = 'middle';
|
||||
if (size && size == "dense") {
|
||||
sizeType = 'small';
|
||||
}
|
||||
|
||||
return (
|
||||
<Select {...validProps} className={classNames.join(' ')} style={style} classNames={{ popup: { root: "tempo-select-dropdown" } }} onChange={onChangeInternal} autoClearSearchValue={false} size={sizeType}>
|
||||
{validChildren}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
// Retype Ant's Option and OptGroup as Tempo DropDown.Item and DropDown.ItemGroup
|
||||
DropDown.Item = Select.Option as React.FC<IDropDownItemProps>;
|
||||
DropDown.ItemGroup = Select.OptGroup as React.FC<IDropDownItemGroupProps>;
|
||||
|
||||
export default DropDown;
|
||||
@@ -0,0 +1,4 @@
|
||||
import DropDown, { IDropDownProps, IDropDownItemProps, IDropDownItemGroupProps, DropDownValue } from "./DropDown";
|
||||
|
||||
export default DropDown;
|
||||
export { IDropDownProps, IDropDownItemProps, IDropDownItemGroupProps, DropDownValue };
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import Empty from './Empty';
|
||||
import WarningIcon from '../icon/WarningIcon';
|
||||
import Button from '../button/Button';
|
||||
|
||||
test('Empty displayed with title and icon', () => {
|
||||
render(<>
|
||||
<Empty title='Empty Title' icon={<WarningIcon />} />
|
||||
</>);
|
||||
|
||||
// get button by text
|
||||
screen.getByText("Empty Title");
|
||||
// get icon
|
||||
screen.getByRole("img", { name: "warning" });
|
||||
});
|
||||
|
||||
test('Empty displayed with title, description and action', () => {
|
||||
render(<>
|
||||
<Empty title='Empty Title'
|
||||
description='Empty Description'
|
||||
actions={<Button>Optional CTA</Button>} />
|
||||
</>);
|
||||
|
||||
|
||||
screen.getByText("Empty Title");
|
||||
screen.getByText("Empty Description");
|
||||
screen.getByText("Optional CTA");
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from "react";
|
||||
import { Empty as AntEmpty } from "antd";
|
||||
import Text from '../text/Text';
|
||||
import IIconProps from '../icon/IIconProps';
|
||||
|
||||
export interface IEmptyProps {
|
||||
/** Contains buttons*/
|
||||
actions?: React.ReactNode;
|
||||
|
||||
/** Title of header*/
|
||||
title?: string | React.ReactNode;
|
||||
|
||||
/** Customize description*/
|
||||
description?: React.ReactNode;
|
||||
|
||||
/** Allow for a choice between InboxOutlined and Warning icons*/
|
||||
icon?: React.ReactElement<IIconProps>;
|
||||
|
||||
/** Additional styles. Use sparingly*/
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty serves as a placeholder when there's no content to show in a particular section or view.
|
||||
* @beta
|
||||
*/
|
||||
const Empty: React.FC<IEmptyProps> = (props) => {
|
||||
let { actions, title, description, style, ...validProps } = props;
|
||||
|
||||
let header: React.ReactNode = <></>;
|
||||
|
||||
if (title) {
|
||||
const marginBottom = description != null ? 4 : 0;
|
||||
header = <Text.Heading level={4} color="secondary" marginBottom={marginBottom}>{title}</Text.Heading>;
|
||||
}
|
||||
|
||||
if (description) {
|
||||
header = (
|
||||
<>
|
||||
{header}
|
||||
<Text color="secondary" strong>{description}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let icon: React.ReactElement<IIconProps> | null = null;
|
||||
let imageHeight: number | undefined = 0;
|
||||
|
||||
if (props.icon) {
|
||||
icon = React.cloneElement(props.icon, {
|
||||
size: 24,
|
||||
color: "secondary"
|
||||
});
|
||||
|
||||
imageHeight = icon.props.size;
|
||||
}
|
||||
|
||||
return (
|
||||
<AntEmpty
|
||||
{...validProps}
|
||||
description={header}
|
||||
image={icon}
|
||||
styles={{ image: { height: imageHeight } }}
|
||||
style={style}
|
||||
>
|
||||
{actions}
|
||||
</AntEmpty>
|
||||
)
|
||||
};
|
||||
|
||||
export default Empty;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Empty, { IEmptyProps } from "./Empty";
|
||||
|
||||
export default Empty;
|
||||
export { IEmptyProps };
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as React from 'react';
|
||||
import { NotAllowed, NotFound, Error } from '../errorpages';
|
||||
import { Modal } from 'antd';
|
||||
import WarningIcon from '../icon/WarningIcon';
|
||||
|
||||
export type ErrorType = 'promise' | 'generic' | 'none';
|
||||
|
||||
export class ErrorBoundaryEvent {
|
||||
source: ErrorType;
|
||||
error: any = null;
|
||||
isPreventDefault = false;
|
||||
|
||||
constructor(source: ErrorType, error: any) {
|
||||
this.source = source;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
preventDefault() {
|
||||
this.isPreventDefault = true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IErrorBoundaryProps {
|
||||
/** Callback for when errors are detected. Use evt.preventDefault() to prevent default error notification */
|
||||
onError?: (evt: ErrorBoundaryEvent) => void;
|
||||
|
||||
/** Allow errorboundary to catch react errors only or all errors. Set it to true if using ErrorBoundary inside your components */
|
||||
disableGlobalJavascriptErrorHandling?: boolean;
|
||||
|
||||
/** Whether to display 404 error as a modal or screen replacement. Defaults to false */
|
||||
show404ErrorInModal?: boolean;
|
||||
|
||||
/** Wheter to display 403 error as a modal or screen replacement. Defaults to false */
|
||||
show403ErrorInModal?: boolean;
|
||||
}
|
||||
|
||||
interface IErrorBoundaryState {
|
||||
errorType: ErrorType;
|
||||
error: any;
|
||||
hasReactError: boolean;
|
||||
hasShownAlert: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
*/
|
||||
class ErrorBoundary extends React.Component<IErrorBoundaryProps, IErrorBoundaryState> {
|
||||
constructor(props: IErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
errorType: 'none',
|
||||
error: null,
|
||||
hasReactError: false,
|
||||
hasShownAlert: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static getDerivedStateFromError(error: any) {
|
||||
return { errorType: 'generic', error: error, hasReactError: true };
|
||||
}
|
||||
|
||||
handleGenericError(event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) {
|
||||
this.handleError('generic', error ?? event);
|
||||
return true;
|
||||
}
|
||||
|
||||
handlePromiseError(ev: PromiseRejectionEvent) {
|
||||
this.handleError('promise', ev.reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.disableGlobalJavascriptErrorHandling) {
|
||||
this.handleGenericError = this.handleGenericError.bind(this);
|
||||
this.handlePromiseError = this.handlePromiseError.bind(this);
|
||||
// Catches event handler errors that ErrorBoundary does not
|
||||
window.addEventListener('error', this.handleGenericError);
|
||||
|
||||
// Catches promise / fetch errors that ErrorBoundary does not
|
||||
window.addEventListener('unhandledrejection', this.handlePromiseError)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (!this.props.disableGlobalJavascriptErrorHandling) {
|
||||
window.removeEventListener('error', this.handleGenericError);
|
||||
window.removeEventListener('unhandledrejection', this.handlePromiseError);
|
||||
}
|
||||
}
|
||||
|
||||
handleError(errorType: ErrorType, error: any) {
|
||||
// if error is nothing, ignore
|
||||
if (error == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Script error. is the generic message for errors that come from 3rd party js files that are hosted in a separate domain.
|
||||
// For example, WalkMe errors happen when ad-blocked is turned on
|
||||
if (error.message?.indexOf('Script error.') > -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ignore resize observer errors from unknown issue with Ant dropdown
|
||||
if (error.message?.indexOf('ResizeObserver loop') > -1) {
|
||||
// in local env, this prevents dev server from displaying error overlay
|
||||
const resizeObserverErrDiv = document.getElementById(
|
||||
'webpack-dev-server-client-overlay-div'
|
||||
);
|
||||
const resizeObserverErr = document.getElementById(
|
||||
'webpack-dev-server-client-overlay'
|
||||
);
|
||||
if (resizeObserverErr) {
|
||||
resizeObserverErr.setAttribute('style', 'display: none');
|
||||
}
|
||||
if (resizeObserverErrDiv) {
|
||||
resizeObserverErrDiv.setAttribute('style', 'display: none');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ignore state transition errors from unknown issue with Ant dropdown
|
||||
if (error.message?.indexOf('Cannot update during an existing state transition') > -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ignore unknown Identity Server issue with invalid grant when micro-app reopens JAZZ-24370
|
||||
if (error.message?.indexOf('invalid_grant') > -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.setState({ errorType: errorType, error: error });
|
||||
}
|
||||
|
||||
render() {
|
||||
// if no errors, just show normal component tree
|
||||
if (this.state.errorType === 'none') {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
// for react component tree error, we should always override component tree to avoid infinite loop
|
||||
if (this.state.hasReactError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
// allow consumer to provide their own error handling for non react errors
|
||||
if (this.props.onError != null) {
|
||||
const evt = new ErrorBoundaryEvent(this.state.errorType, this.state.error);
|
||||
this.props.onError(evt);
|
||||
if (evt.isPreventDefault) {
|
||||
this.setState({ errorType: 'none', error: null, hasReactError: false });
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// special handling for 404 and 403
|
||||
if (this.state.errorType === 'promise') {
|
||||
if (this.state.error.status === 404 && !this.props.show404ErrorInModal) {
|
||||
return <NotFound />;
|
||||
}
|
||||
if (this.state.error.status === 403 && !this.props.show403ErrorInModal) {
|
||||
return <NotAllowed />;
|
||||
}
|
||||
}
|
||||
if (this.state.hasShownAlert === false) {
|
||||
this.setState({ hasShownAlert: true });
|
||||
// for unhandled javascript or promise errors, just show an alert
|
||||
Modal.error({
|
||||
title: 'Error',
|
||||
icon:<WarningIcon color="error" size={24} />,
|
||||
content: 'An unknown error occurred. Try again and contact us if the issue continues.',
|
||||
onOk: () => {
|
||||
this.setState({ errorType: 'none', error: null, hasReactError: false, hasShownAlert: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ErrorBoundary, { IErrorBoundaryProps, ErrorBoundaryEvent, ErrorType } from "./ErrorBoundary";
|
||||
|
||||
export default ErrorBoundary;
|
||||
export { IErrorBoundaryProps, ErrorBoundaryEvent, ErrorType };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user