Merge pull request 'chore: initial deploy for code base' (#1) from feature/deploy-initial-code-base into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-05-12 09:42:10 -05:00
345 changed files with 37208 additions and 1 deletions
+183
View File
@@ -0,0 +1,183 @@
# Contributing Guidelines
Welcome, brave engineer. Before you type `git commit -m "oops"` and vanish for 3 weeks of PTO, let's align on how commits work in this repo.
We follow [**Conventional Commits**](https://www.conventionalcommits.org/) with **semantic versioning**.\
This gives us:
- Automatic changelogs (no one likes writing them manually)
- Predictable version bumps
- Clear history that future-you (or your replacement) can actually read
---
## 1. Commit Message Format
Every commit **MUST** follow this format:
```
<type>(<scope>): <description>
[optional body]
[optional BREAKING CHANGE: ...]
[optional footer(s)]
```
- \`\` → what you did
- \`\` *(optional, but strongly encouraged)* → where you did it
- \`\` → short explanation (imperative tense)
- **Body** → additional details, rationale, or context (wrap at 72 chars if possible)
- **Footer** → metadata such as references to backlog items or related issues
> ❗ There must be a blank line separating `BREAKING CHANGE:` from any footer lines.
### Examples
```
feat(auth): add token refresh endpoint
Added a new endpoint for refreshing authentication tokens to reduce
full login frequency.
Refs: JIRA-1234
```
```
fix(api): correctly parse null values
Previously, null values in payloads caused 500 errors due to improper
validation. Updated parser to handle null safely.
BREAKING CHANGE: Parser behavior has changed for null payloads.
Refs: JIRA-9876
```
---
## 2. Allowed Commit Types
| Type | When to Use |
| ---------- | ----------------------------------------- |
| `build` | Build system changes (configs, scripts) |
| `feat` | A new feature |
| `fix` | A bug fix |
| `docs` | Documentation updates |
| `style` | Non-functional style changes (formatting) |
| `refactor` | Code change that isnt a bug or feature |
| `test` | Adding/fixing tests |
| `chore` | Maintenance tasks (deps, configs) |
---
## 3. Scopes
Scopes keep commits relevant. Commonly you'll find service, or module names, used as the scope:
```
feat(api): add user endpoint
fix(web): handle expired token
```
> While unrelated areas should be split into separate commits, multiple scopes can be combined in a single commit if they are related:
`refactor(web,api): clean up regex for widget payloads`
If you're touching multiple unrelated areas, split the work.\
If youre touching the **entire repo**, you may omit the scope (e.g. `chore: update prettier config`).
---
## 4. Commit Body
Use the body to:
- Explain **why** the change was made
- Add context or reasoning (if its not obvious from the diff)
- Mention relevant technical details
> ❗ Keep the body wrapped at 72 characters where possible for better readability in CLI tools.
Example:
```
fix(auth): improve token handling
Added stricter validation to prevent expired tokens from being used
in refresh calls. This fixes intermittent 401 errors for long-lived
sessions.
```
---
## 5. Commit Footer
Use the footer for:
- `BREAKING CHANGE:` declarations (with a blank line after it)
- References to backlog items, tickets, or issues
- Co-authorship metadata if needed (`Co-authored-by:`)
Example:
```
feat(ui): add dark mode toggle
Added a dark mode toggle in the user settings page.
BREAKING CHANGE: Removed old theme switcher.
Refs: JIRA-4567
```
---
## 6. Linting & Enforcement
This repo uses:
- [**commitlint**](https://github.com/conventional-changelog/commitlint) to reject bad commit messages
- [**husky**](https://typicode.github.io/husky) to run lint checks pre-commit
After cloning the repository, run `npm install` to ensure husky hooks are installed and initialized locally. If you skip this step, GitHub Actions will run the same commit linting on your pull requests. Any bad commits that bypass local checks will result in a failed PR check.
If you try to commit `do some stuff`, the hook will fail and mock you.\
Run `npm run commit` if you want a guided prompt.
---
## 7. Pull Requests
- Keep PRs small and focused.
- Please use `--amend` commits or `--fixup` commits where appropriate
- Squash `fixup:` commits, and any commits that dont add value (e.g. `chore: typo`).
- Ensure commit messages still follow Conventional Commits after squashing.
---
## 8. Versioning
We use **semantic versioning**:
- `fix:` → Patch (`1.0.1`)
- `feat:` → Minor (`1.1.0`)
- `BREAKING CHANGE:` → Major (`2.0.0`)
Your commit messages drive the versioning calculation.
---
### TL;DR
- Use Conventional Commits.
- Include a scope if it makes sense.
- Write meaningful bodies when needed.
- Use footers for breaking changes or ticket references.
- Leave a blank line between BREAKING CHANGE and footer.
- Your branch's commit history should tell the story of what you did.
- Bad commits will be rejected by hooks, mocked by your peers, and possibly framed on Slack or in some PowerPoint presentation as "what not to do."
---
Happy committing!
+35 -1
View File
@@ -1,2 +1,36 @@
# tempo
# Tempo
## Prerequisites
1. Make sure you have followed ALL steps in this confluence https://confluence.sdt.local/display/SWPRJ/Setting+up+React and that your npm/node versions are as follows:
* npm- 6.14.16
* node- v14.19.1
2. Clone the tempo and tempodocsite repositories.
## Making Changes
1. Branch off Development **(do not branch off Main)** and make your changes. If you are creating a new feature use `feature/tempo/jira-ticket-goes-here` if you are creating a hot fix use `fix/tempo/jira-ticket-goes-here`.
2. If this is your first time working in the repo you will need to run `npm install`.
3. Make the changes/fixes/updates you need as part of your item.
4. Run `npm run wbp` to build Tempo and create the updated `tempo.api.md` file. This is a must do in order to create the latest markdown/metadata file that others consume when using tempo.
5. Commit your tempo changes to your branch and push.
6. Create a branch off Main in the docsite repo https://github.com/stratadecision/tempodocsiteweb as there is no development branch there.
7. If this is your first time working in the repo you will need to run `npm install`
8. To use your tempo changes in your docsite branch, npm install the pre-release version of those changes from your tempo branch. You can view the pre-release versions by going here to proget https://proget.sdt.local/feeds/npm/@strata/tempo/versions/all. Run `npm run start` to view localhost version of docsite and see your changes.
9. If there are issues with your tempo changes, return to step 3 and make updates in your tempo repo branch. Repeat steps 3-5 along with installing the newest pre-release version of your tempo branch until your tempo changes work as you'd expect.
10. Once you have the tempo changes finished update the docsite by adding new examples/updating existing examples/etc as it applies to the scope of your changes.
11. After verifying your tempo changes work, create a tempo PR pointed at development branch, the codeowners file will automatically put required reviewers on the PR.
**Make sure you are pointing your PR to Development and not Main**
12. Create a docsite PR with your changes, the codeowners file will automatically put required reviews on the PR.
13. Once Carmen & Jacky have approved your Tempo PR merge it into development.
14. After your Tempo PR is merged into development and the build has finished, run `npm install @strata/tempo@beta` in your docsite branch. Commit the package.json and package-lock.json changes.
15. Once Carmen & Jacky have approved your docsite PR, merge it. Once your Docsite PR is merged and the build has finished, you can see your change in Docsite Beta url. https://tempo-beta.dev.stratanetwork.net/
## Build Status
[![build](https://github.com/stratadecision/tempo/actions/workflows/build.yaml/badge.svg)](https://github.com/stratadecision/tempo/actions/workflows/build.yaml)
## Branching and Versioning
| branch | version format | example |
| ---------- | --------------------- | --------------- |
| master | #.#.# | 1.2.3 |
| feature/\* | #.#+1.0-featureName.# | 1.3.0-newfeat.1 |
| fix/\* | #.#.#+1-fixName.# | 1.2.4-bug.1 |
See our [confluence page](https://confluence.sdt.local/display/DOP/Branching+and+Versioning) for more information
+1717
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"scripts": {
"commit": "commit",
"prepare": "husky"
},
"devDependencies": {
"@commitlint/cli": "^19.2.1",
"@commitlint/prompt-cli": "^19.2.1",
"@commitlint/config-conventional": "^19.1.0",
"husky": "^9.0.11"
}
}
+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");
});

Some files were not shown because too many files have changed in this diff Show More