chore: initial deploy for code base

This commit is contained in:
Thom Lamb
2026-05-12 09:39:01 -05:00
parent 6b355f82d1
commit 0a80e96b32
345 changed files with 37208 additions and 1 deletions
+331
View File
@@ -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
// },
//
// . . .
}
}
}
+7
View File
@@ -0,0 +1,7 @@
const babelOptions = require('@strata/babel');
module.exports = function (api) {
api.cache(true);
return babelOptions();
}
+6
View File
@@ -0,0 +1,6 @@
const rucksack = require('rucksack-css');
const autoprefixer = require('autoprefixer');
module.exports = {
plugins: [rucksack(), autoprefixer()],
};
+28
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
preset: 'ts-jest/presets/js-with-babel',
globals: {
'ts-jest': {
babelConfig: true,
}
},
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js']
};
+19228
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -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/"
}
}
+61
View File
@@ -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");
});
+96
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import Accordion, { IAccordionProps, IAccordionItem } from "./Accordion";
export default Accordion;
export { IAccordionProps, IAccordionItem };
+47
View File
@@ -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" });
});
+78
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import ActionBar, { IActionBarProps } from "./ActionBar";
export default ActionBar;
export { IActionBarProps };
+39
View File
@@ -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();
});
+64
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import Banner, { IBannerProps } from "./Banner"
export default Banner;
export { IBannerProps }
+49
View File
@@ -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("");
});
+77
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import Button, { IButtonProps, ButtonType } from "./Button"
export default Button;
export { IButtonProps, ButtonType }
+37
View File
@@ -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');
});
+124
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import ButtonMenu, { IButtonMenuProps, IButtonMenuClickEvent } from "./ButtonMenu"
export default ButtonMenu;
export { IButtonMenuProps, IButtonMenuClickEvent }
+69
View File
@@ -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');
});
+156
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import Card, { ICardProps } from "./Card"
export default Card;
export { ICardProps }
+86
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import Cascader, { ICascaderProps, ICascaderOption } from "./Cascader";
export default Cascader;
export { ICascaderProps, ICascaderOption };
+48
View File
@@ -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();
});
+56
View File
@@ -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;
+4
View File
@@ -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();
});
+42
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import CheckboxGroup, { ICheckboxGroupProps, CheckboxValueType } from "./CheckboxGroup"
export default CheckboxGroup;
export { ICheckboxGroupProps, CheckboxValueType };
+70
View File
@@ -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;
+84
View File
@@ -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");
});
+60
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
import Chips, { IChipsProps, IChip } from "./Chips"
import Chip, { IChipProps } from "./Chip";
export default Chips;
export { IChipsProps, IChip, Chip, IChipProps };
+48
View File
@@ -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');
});
+61
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import ColorPicker, { IColorPickerProps, IColorsPreset } from "./ColorPicker";
export default ColorPicker;
export { IColorPickerProps, IColorsPreset };
+3
View File
@@ -0,0 +1,3 @@
import useWindowSize, { IWindowSize } from "./useWindowSize";
export { useWindowSize, IWindowSize };
+37
View File
@@ -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;
+67
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import DataCell, { IDataCellProps } from "./DataCell"
export default DataCell;
export { IDataCellProps };
+76
View File
@@ -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;
+133
View File
@@ -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;
+91
View File
@@ -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;
+91
View File
@@ -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;
+37
View File
@@ -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;
+113
View File
@@ -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;
+35
View File
@@ -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;
+44
View File
@@ -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;
+45
View File
@@ -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;
+86
View File
@@ -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;
}
+22
View File
@@ -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[];
}
+7
View File
@@ -0,0 +1,7 @@
import ICellArgs from "./ICellArgs";
export default interface ICellClickArgs extends ICellArgs {
/** Is the cell editable */
isEditable: boolean;
}
+6
View File
@@ -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 {
}
+8
View File
@@ -0,0 +1,8 @@
import IGetColumnPropsArgs from "./IGetColumnPropsArgs";
import { ColumnProps } from "primereact/column";
export interface IColumn {
className: string;
getColumnProps: (args: IGetColumnPropsArgs) => ColumnProps;
}
+17
View File
@@ -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';
}
+108
View File
@@ -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;
+61
View File
@@ -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;
+34
View File
@@ -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;
+62
View File
@@ -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;
+38
View File
@@ -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
};
+694
View File
@@ -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");
});
});
+38
View File
@@ -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;
+91
View File
@@ -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;
+10
View File
@@ -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);
});
});
+46
View File
@@ -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;
+14
View File
@@ -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
};
+38
View File
@@ -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;
+22
View File
@@ -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;
+22
View File
@@ -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;
+208
View File
@@ -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;
}
}
+6
View File
@@ -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 };
+44
View File
@@ -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');
});
+105
View File
@@ -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;
+16
View File
@@ -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');
});
+62
View File
@@ -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;
+117
View File
@@ -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;
+67
View File
@@ -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;
+16
View File
@@ -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');
});
+64
View File
@@ -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;
+14
View File
@@ -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
};
+55
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import Divider, { IDividerProps } from "./Divider";
export default Divider;
export { IDividerProps };
+103
View File
@@ -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;
+4
View File
@@ -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 }
+115
View File
@@ -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);
})
+287
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import DropDown, { IDropDownProps, IDropDownItemProps, IDropDownItemGroupProps, DropDownValue } from "./DropDown";
export default DropDown;
export { IDropDownProps, IDropDownItemProps, IDropDownItemGroupProps, DropDownValue };
+29
View File
@@ -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");
});
+71
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import Empty, { IEmptyProps } from "./Empty";
export default Empty;
export { IEmptyProps };
+182
View File
@@ -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;
+4
View File
@@ -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