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