diff --git a/.eslintrc b/.eslintrc index ee0db140e..a9725f25d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,7 +10,7 @@ "react-hooks" ], "parserOptions": { - "ecmaVersion": 8, + "ecmaVersion": 2018, "ecmaFeatures": { "jsx": true } diff --git a/.gitignore b/.gitignore index 36d38ef55..a4c477682 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,18 @@ -node_modules -.tmp -**/npm-debug.log -.DS_Store +*.log *.swp -dist +.DS_Store +.envrc +.idea/ +.tmp/ +.vscode/ app/src/js/config/local.js -gulp-cache -/artifacts -/tmp/ -/docker/html/ -yarn.lock +artifacts/ +cypress/screenshots/ +cypress/videos/ +dist/ +docker/html/ +gulp-cache/ +node_modules/ test/fake-api/db/*.json -cypress/screenshots -cypress/videos -.vscode -.idea/ +tmp/ +yarn.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 74823c83e..666f4ee5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,96 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [v1.8.0] + +### Added + +- **CUMULUS-1515** + - filter capability to workflow overview page. + +- **CUMULUS-1526** + - Add a copy rule button + +- **CUMULUS-1538** + - Add ability to expand size of visual on execution details page + +- **CUMULUS-1646** + - Add 'Results Per Page' dropdown for tables that use pagination + +- **CUMULUS-1677** + - Updates the user experience when re-ingesting granules. Adds Modal flow for better understanding. + +- **CUMULUS-1798** + - Add a refresh button + - Add individual cancel buttons for date time range inputs + +- **CUMULUS-1822** + - Added dynamic form validation as user types + +### Changed + +- **CUMULUS-1460** + - Update dashboard headers overall + - Move remaining "add" buttons to body content + +- **CUMULUS-1467** + - Change the metrics section on the home page to update based on datepicker time period. + +- **CUMULUS-1509** + - Update styles on grnaules page + +- **CUMULUS-1525** + - Style changes for rules overview page + +- **CUMULUS-1527** + - Style changes for individual rule page + +- **CUMULUS-1528** + - Change add/copy rule form from raw JSON input to individual form fields. + Workflow, Provider, and Collection inputs are now dropdowns populated with + currently available items. + +- **CUMULUS-1537** + - Update execution details page format + - Move execution input and output json to modal + +- **CUMULUS-1538** + - Update executions details page styles + +- **CUMULUS-1787** + - Changes `listCollections` action to hit `/collections/active` endpoint when timefilters are present (requires Cumulus API v1.22.1) + +- **CUMULUS-1798** + - Change the 12HR/24HR Format selector from radio to dropdown + - Hide clock component in react-datetime-picker + +- **CUMULUS-1790** + - Changes default values and visuals for home page's datepicker. When the page loads, it defauls to display "Recent" data, which is the previous 24 hours with no end time. + +### Fixed + +- **CUMULUS-1813** + - Fixed CSS for graph on Execution status page + - Removed Datepicker from Execution status page + +- **CUMULUS-1822** + - Fixed no user feedback/errors when submitting a blank form + ## [v1.7.2] - 2020-03-16 ### Added +- **CUMULUS-1535** + - Adds a confirmation modal when editing a rule + - **CUMULUS-1758** - Adds the ability to resize table columns ### Changed +- **CUMULUS-1693** + - Updates the bulk delete collection flow + - **CUMULUS-1758** - Updates table implementation to use [react-table](https://github.com/tannerlinsley/react-table) @@ -271,7 +352,8 @@ Fix for serving the dashboard through the Cumulus API. - Versioning and changelog [CUMULUS-197] by @kkelly51 -[Unreleased]: https://github.com/nasa/cumulus-dashboard/compare/v1.7.2...HEAD +[Unreleased]: https://github.com/nasa/cumulus-dashboard/compare/v1.8.0...HEAD +[v1.8.0]: https://github.com/nasa/cumulus-dashboard/compare/v1.7.2...v1.8.0 [v1.7.2]: https://github.com/nasa/cumulus-dashboard/compare/v1.7.1...v1.7.2 [v1.7.1]: https://github.com/nasa/cumulus-dashboard/compare/v1.7.0...v1.7.1 [v1.7.0]: https://github.com/nasa/cumulus-dashboard/compare/v1.6.1...v1.7.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 116f454d9..f95cd9b49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ If you want to submit your own contributions, follow these steps: * Fork the Cumulus Dashboard repo * Create a new branch from the branch you'd like to contribute to -* If an issue does't already exist, submit one (see above) +* If an issue doesn't already exist, submit one (see above) * [Create a pull request](https://help.github.com/articles/creating-a-pull-request/) from your fork into the target branch of the nasa/cumulus-dashboard repo * Be sure to [mention the corresponding issue number](https://help.github.com/articles/closing-issues-using-keywords/) in the PR description, i.e. "Fixes Issue #10" * If your contribution requires a specific version of the Cumulus API, bump (or add) the version in `app/src/js/config/index.js`. diff --git a/README.md b/README.md index ef51410b6..6e9999a53 100644 --- a/README.md +++ b/README.md @@ -23,21 +23,22 @@ The information needed to configure the dashboard is stored at `app/src/js/confi The following environment variables override the default values in `config.js`: -| Env Name | Description | -| -------- | ----------- | -| HIDE_PDR | whether to hide the PDR menu, default to true | -| DAAC\_NAME | e.g. LPDAAC, default to Local | -| STAGE | e.g. UAT, default to development | -| LABELS | gitc or daac localization (defaults to daac) | -| APIROOT | the API URL. This must be set by the user as it defaults to example.com | -| AUTH_METHOD | The type of authorization method protecting the Cumulus API. [launchpad or earthdata] Default: earthdata | -| ENABLE\_RECOVERY | If true, adds recovery options to the granule and collection pages. default: false | -| KIBANAROOT | \ Should point to a Kibana endpoint. Must be set to examine distribution metrics details. | -| SHOW\_TEA\_METRICS | \ display metrics from Thin Egress Application (TEA). default: true | -| SHOW\_DISTRIBUTION\_API\_METRICS | \ Display metrics from Cumulus Distribution API. default: false | -| ESROOT | \ Should point to an Elasticsearch endpoint. Must be set for distribution metrics to be displayed. | -| ES\_USER | \ Elasticsearch username, needed when protected by basic authorization | -| ES\_PASSWORD | \ Elasticsearch password,needed when protected by basic authorization | +| Env Name | Description | Default | +| -------- | ----------- | -------- | +| HIDE\_PDR | Whether to hide the PDR menu. | *true* | +| AWS\_REGION | Region in which Cumulus API is running. | *us-west-2* | +| DAAC\_NAME | e.g. LPDAAC, | *Local* | +| STAGE | e.g. PROD, UAT, | *development* | +| LABELS | gitc or daac localization. | *daac* | +| APIROOT | the API URL. This must be set by the user. | *example.com* | +| AUTH_METHOD | The type of authorization method protecting the Cumulus API. [launchpad or earthdata] | *earthdata* | +| ENABLE\_RECOVERY | If true, adds recovery options to the granule and collection pages. | *false* | +| KIBANAROOT | \ Should point to a Kibana endpoint. Must be set to examine distribution metrics details. | | +| SHOW\_TEA\_METRICS | \ display metrics from Thin Egress Application (TEA). | *true* | +| SHOW\_DISTRIBUTION\_API\_METRICS | \ Display metrics from Cumulus Distribution API.| *false* | +| ESROOT | \ Should point to an Elasticsearch endpoint. Must be set for distribution metrics to be displayed. | | +| ES\_USER | \ Elasticsearch username, needed when protected by basic authorization | | +| ES\_PASSWORD | \ Elasticsearch password,needed when protected by basic authorization | | ## Building or running locally @@ -76,7 +77,7 @@ To build the dashboard: #### Build the dashboard to be served by the Cumulus API. -With the Cumulus API it is possible to [serve the dashboard from an s3 Bucket](https://nasa.github.io/cumulus-api/#serve-the-dashboard-from-a-bucket). If you wish to do this, you must build the dashboard with the environment variable `SERVED_BY_CUMULUS_API` set to `true`. This configures the dashboard to work from the Cumulus `dashboard` endpoint. +With the Cumulus API it is possible to [serve the dashboard from an s3 Bucket](https://nasa.github.io/cumulus-api/#serve-the-dashboard-from-a-bucket). If you wish to do this, you must build the dashboard with the environment variable `SERVED_BY_CUMULUS_API` set to `true`. This configures the dashboard to work from the Cumulus `dashboard` endpoint. This option should be considered when you can't serve the dashboard from behind CloudFront, for example in NGAP sandbox environments. If you wish to serve the dashboard from behind [CloudFront](https://aws.amazon.com/cloudfront/). Build a `dist` with your configuration for `APIROOT` and omitting `SERVED_BY_CUMULUS_API` and follow the cumulus operator docs on [serving the dashboard from CloudFront](https://nasa.github.io/cumulus/docs/next/operator-docs/serve-dashboard-from-cloudfront). The compiled files will be placed in the `dist` directory. @@ -120,7 +121,7 @@ These are started and stopped with the commands: $ npm run start-localstack $ npm run stop-localstack ``` -After these containers are running, you can start a cumulus API locally in a terminal window `npm run serve-api`, the dashboard in another window. `[APIROOT=http://localhost:5001] npm run serve` and finally cypress in a third window. `npm run cypress`. +After these containers are running, you can start a cumulus API locally in a terminal window `npm run serve-api`, the dashboard in another window. `[SHOW_DISTRIBUTION_API_METRICS=true ESROOT=http://example.com APIROOT=http://localhost:5001] npm run serve` and finally cypress in a third window. `npm run cypress`. Once the docker app is running, If you would like to see sample data you can seed the database. This will load the same sample data into the application that is used during cypress testing. ```bash @@ -134,7 +135,7 @@ The cumulusapi docker service is started and stopped: $ npm run start-cumulusapi $ npm run stop-cumulusapi ``` -Then you can run the dashboard locally (without docker) `[APIROOT=http://localhost:5001] npm run serve` and open cypress tests `npm run cypress`. +Then you can run the dashboard locally (without docker) `[SHOW_DISTRIBUTION_API_METRICS=true ESROOT=http://example.com APIROOT=http://localhost:5001] npm run serve` and open cypress tests `npm run cypress`. The docker compose stack also includes a command to let a developer start all development containers with a single command. @@ -277,7 +278,7 @@ Serve the cumulus API (separate terminal) Serve the dashboard web application (another terminal) ```bash - $ [APIROOT=http://localhost:5001] npm run serve + $ [SHOW_DISTRIBUTION_API_METRICS=true ESROOT=http://example.com APIROOT=http://localhost:5001] npm run serve ``` If you're just testing dashboard code, you can generally run all of the above commands as a single docker-compose stack. diff --git a/app/src/css/_badges.scss b/app/src/css/_badges.scss new file mode 100644 index 000000000..260bd6cb6 --- /dev/null +++ b/app/src/css/_badges.scss @@ -0,0 +1,33 @@ +.num--title { + display: inline-block; + vertical-align: middle; + border-radius: 4px; + padding: 0.3em .5em .5em .5em; + margin-left: 10px; + background: $ocean-blue; + color: $white; + line-height: .8; + text-transform: uppercase; + text-shadow: 1px 1px 1px rgba(0,0,0,0.2); + font-size: 1em; + font-weight: $base-font-regular; + -webkit-font-smoothing: antialiased; + transition: background-color 0.2s linear; +} + +.status--badge { + padding: .5em; + border-radius: 4px; + margin-left: 5px; + color: $white; + text-transform: uppercase; + font-weight: $base-font-regular; + display: inline-block; + &__enabled { + background-color: #30AD66; + } + &__disabled { + background-color: #36342D; + } +} + diff --git a/app/src/css/_base.scss b/app/src/css/_base.scss index 2796bf881..ad29753f1 100644 --- a/app/src/css/_base.scss +++ b/app/src/css/_base.scss @@ -114,7 +114,6 @@ a:active { .link--learn-more { margin-top: 1em; - display: inline-block; float: right; } @@ -165,6 +164,7 @@ a:active { .header { background-color: $ocean-blue; border-bottom: 1px solid rgba(255, 255, 255, .06); + z-index: 2; h1 { float: left; @@ -391,17 +391,25 @@ a:active { .page__section { @extend .clearfix; - &:last-of-type { + /*&:last-of-type { margin-bottom: 3em; - } + }*/ } .page_section__overview { margin-top: 25px; } +.page__section__header-wrapper { + margin-bottom: 1em; +} + +.page_section.metrics--overview .heading__wrapper--border { + margin-top: 0em; +} + .page__section.datetime{ - margin-bottom: 8em; + margin-bottom: 7.5em; } .collection__options--top ul{ @@ -414,7 +422,7 @@ a:active { } .page__section__controls{ - margin-bottom: 2em; + margin-bottom: 1.5em !important; } .page__section--small { @@ -422,9 +430,7 @@ a:active { } .page__section--top{ - /*margin-bottom: 25px;*/ padding: 25px 0 25px 0; - /*border-top: 1px solid #E2DFDF;*/ background-color: #1E6B9D; box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.12); } @@ -606,17 +612,17 @@ a:active { .status--process { margin-top: 1.5em; + font-weight: $base-font-semibold; dd, dt { display: inline-block; font-size: .9em; } dd { font-weight: $base-font-semibold; - margin-bottom: 10px; } dt { - color: $grey; margin-right: 0.5em; + color: $dark-grey; } .running, .enabled { @@ -640,6 +646,21 @@ a:active { background-color: $error-red; } } + + .execution-status { + &-succeeded { + color: $light-green; + } + + &-running { + color: $yellow; + } + + &-failed { + color: $error-red; + } + } + } .app__target--container { @@ -684,6 +705,14 @@ a:active { } } +.Collapsible__trigger { + align-self: flex-start; + cursor: pointer; + margin: 1em 0; + color: $ocean-blue; + border-bottom: 1px solid rgba(34, 118, 172, .2); +} + /************************************************** Footer **************************************************/ diff --git a/app/src/css/_buttons.scss b/app/src/css/_buttons.scss index a813ff697..6b2376ea4 100644 --- a/app/src/css/_buttons.scss +++ b/app/src/css/_buttons.scss @@ -22,13 +22,15 @@ color: $white; } - &:disabled{ + &:disabled, + &--disabled { opacity: 0.6; pointer-events: none; } &:hover{ background-color: $midnight-blue; + color: $white; } &--oauth{ @@ -37,7 +39,11 @@ &--small{ font-size: .86em; - padding: .4em 1.5em .3em 2.5em; + padding: .4em 1.5em .4em 2.5em; + + &.button--no-icon { + padding: .4em 1.5em; + } } &--large{ @@ -46,6 +52,10 @@ font-size: 1em; } + &--no-icon { + padding: .65em 1.2em; + } + &--white, &--white:visited { color: $midnight-blue; background-color: $white; @@ -66,17 +76,18 @@ } } - &--primary, &--primary{ - &:hover, &:visited { + &--primary, &--primary:visited { + &:hover { background-color: darken($light-green, 10%); color: $white; border: 0; } } - &--secondary, &--secondary{ - &:hover, &:visited { - background-color: $dolphin-grey; + &--secondary, &--secondary:visited{ + background-color: $dolphin-grey; + &:hover { + background-color: darken($dolphin-grey, 20%); } } @@ -250,8 +261,11 @@ content: '\f0ab'; font-weight: 900; left: 10px; - - } + } + + &.button__animation:hover:before { + visibility: hidden; + } } &--copy{ @@ -292,6 +306,37 @@ font-weight: 900; visibility: hidden; } + + &--enable { + background-color: $light-green; + position: relative; + + &:before { + color: $white; + position: absolute; + font-family: 'FontAwesome'; + content: '\f205'; + font-weight: 900; + top: 4px; + left: 10px; + } + } + + &--disable { + background-color: $light-green; + position: relative; + + &:before { + color: $white; + position: absolute; + font-family: 'FontAwesome'; + content: '\f204'; + font-weight: 900; + top: 4px; + left: 10px; + } + } + /*******Button Animations & Transitions ******/ &--loading { @@ -320,10 +365,30 @@ -moz-transition: all 0.3s; transition: all 0.3s; } + + &:hover { + padding: .7em 3em .7em 1em; + } + + &.button--small { + &:hover { + padding: .4em 3em .4em 1em; + } + + &:after { + top: unset; + } + } } - - &__animation:hover { - padding: .7em 3em .7em 1em; + + &__icon--animation { + cursor: pointer; + transition: font-size 0.2s; + font-size: 1em; + + &:hover { + font-size: 1.1em; + } } &__arrow:after { @@ -366,7 +431,11 @@ font-weight: 900; left: 10px; - } + } + + &.form-group__element:hover:before { + visibility: visible; + } } &__bulkgranules:hover:before{ @@ -438,6 +507,12 @@ font-weight: 900; left: 10px; } + + &.form-group__element--right { + &:hover:before { + visibility: visible; + } + } } &__goto:hover:before { diff --git a/app/src/css/_chart.scss b/app/src/css/_chart.scss new file mode 100644 index 000000000..408277353 --- /dev/null +++ b/app/src/css/_chart.scss @@ -0,0 +1,101 @@ +.chart__box { + display: inline-block; + width: 50%; + text-align: center; +} + +.chart__container { + height: 400px; +} + +.chart__bar { + fill: $midnight-blue; +} + +.axis__tick, +.axis__line { + stroke: $light-grey; +} + +.axis__text { + font-size: 10px; + fill: $light-grey; +} + +.tooltip { + margin-left: 1em; + margin-top: 1em; + position: fixed; + pointer-events: none; + transition: all 0.1s; + z-index: 99; +} + +.tooltip__inner { + padding: 1em; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.12); + border-radius: 5px; + background: #FFF; + max-width: 240px; +} + +.clusters rect { + fill: transparent; + stroke: #555; + stroke-dasharray: 5 2; + stroke-width: 1px; +} + +text { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serf; + font-size: 14px; + fill: #444444; +} + +rect { + ry: 5; + rx: 5; +} + +.node rect { + stroke: #555; + fill: #fff; + stroke-width: 1px; +} + +.edgePath path { + stroke: #555; + stroke-width: 1.5px; +} + +.cluster .label { + display: none; +} + +.terminus rect { + ry: 25px; + rx: 25px; + fill: #ffda75; +} + +.Succeeded rect { + fill: #2bd62e; +} + +.InProgress rect { + fill: #53c9ed; +} + +.Cancelled rect { + fill: #ddd; +} + +.Failed rect { + fill: #de322f; +} + +svg:not(:root):not(.svg-inline--fa) { + display: block; + margin: 0 auto; + overflow: visible; +} diff --git a/app/src/css/_lists.scss b/app/src/css/_lists.scss index 2cc18a9a3..eca110185 100644 --- a/app/src/css/_lists.scss +++ b/app/src/css/_lists.scss @@ -8,7 +8,7 @@ } dd { width: 20%; - color: $lighter-grey; + color: $dark-grey; &:nth-of-type(odd) { margin-right: 10%; } @@ -16,7 +16,7 @@ dt, dd { display: inline-block; vertical-align: middle; - margin: .5em 0; + margin: .75em 0; } } diff --git a/app/src/css/_normalize.scss b/app/src/css/_normalize.scss index 3a2e91587..486db9097 100755 --- a/app/src/css/_normalize.scss +++ b/app/src/css/_normalize.scss @@ -183,14 +183,6 @@ img { border: 0; } -/** - * Correct overflow not hidden in IE 9/10/11. - */ - -svg:not(:root) { - overflow: hidden; -} - /* Grouping content ========================================================================== */ diff --git a/app/src/css/_typography.scss b/app/src/css/_typography.scss index ebfeaa923..6edb8476e 100644 --- a/app/src/css/_typography.scss +++ b/app/src/css/_typography.scss @@ -44,42 +44,29 @@ h1, h2, h3, h4, h5, h6 { margin-right: .6em; } -.heading__wrapper--border { - border-bottom: 1px solid $lightest-grey; - padding-bottom: .3em; - margin-bottom: 2em; - margin-top: 2em; - h2, h3, h4 { - margin-bottom: .5em; +.heading__wrapper { + &--border { + border-bottom: 1px solid $lightest-grey; + padding-bottom: .3em; + margin-bottom: 1em; + margin-top: 2em; + h2, h3, h4 { + margin-bottom: .5em; + } } + &--topborder { + border-top: 1px solid $lightest-grey; + margin-top: 1em; + padding-top: 1.5em; + + } } + .with-description { margin-bottom: .1em; } -.num--title { - /*font-weight: $base-font-light; - font-size: 90%; - color: $grey; - margin-left: .2em; - */ - display: inline-block; - vertical-align: middle; - border-radius: 4px; - padding: 0.3em .5em .5em .5em; - margin-left: 10px; - background: $ocean-blue; - color: $white; - line-height: .8; - text-transform: uppercase; - text-shadow: 1px 1px 1px rgba(0,0,0,0.2); - font-size: 1em; - font-weight: $base-font-regular; - -webkit-font-smoothing: antialiased; - transition: background-color 0.2s linear; -} - .breadcrumb, .caption { font-size: 0.875em; } \ No newline at end of file diff --git a/app/src/css/main.scss b/app/src/css/main.scss index e353379ab..7d6100efa 100644 --- a/app/src/css/main.scss +++ b/app/src/css/main.scss @@ -23,15 +23,17 @@ @import '../css/modules/modals'; @import '../css/modules/jsonwindow'; @import 'typography'; +@import '../css/badges'; @import '../css/buttons'; @import '../js/components/Form/form'; @import '../js/components/DropDown/DropDown.scss'; @import 'lists'; @import '../css/modules/table'; @import '../js/components/LoadingIndicator/loading-indicator'; -@import "../js/components/Modal/modal"; @import 'pulse'; @import "../js/components/Datepicker/Datepicker.scss"; +@import "../js/components/DatePickerHeader/DatePickerHeader.scss"; +@import "chart"; /********************************** * Bootstrap @@ -51,4 +53,4 @@ * Date Time Picker ***********************************/ -@import 'vendor/datetimepicker/datetimepicker'; \ No newline at end of file +@import 'vendor/datetimepicker/datetimepicker'; diff --git a/app/src/css/modules/_modals.scss b/app/src/css/modules/_modals.scss index 6dc423980..cc28d344d 100644 --- a/app/src/css/modules/_modals.scss +++ b/app/src/css/modules/_modals.scss @@ -93,16 +93,104 @@ font-weight: $base-font-semibold; } -.bulk_granules-modal .modal-dialog{ - overflow-y: initial !important; - max-height:85%; - margin-top: 50px; - margin-bottom:50px; -} +.default-modal { + .modal-body { + text-align: center; + + .error { + word-wrap: break-word; + margin: 1em auto; + font-style: italic; + } + + ul { + margin: 1em; + + li { + font-weight: bold; + } + } + + .dropdown__wrapper { + float: none; + } + } -.bulk_granules-modal .modal-body{ -height: 425px; -overflow-y: auto; + &.bulk_granules { + &.modal-dialog { + overflow-y: initial !important; + max-height:85%; + margin-top: 50px; + margin-bottom:50px; + max-width: 750px; + } + + .modal-body { + height: 425px; + overflow-y: auto; + } + + .button__arrow--md:hover:after { + left: 85%; + } + } + + &.execution__modal { + max-width: 80%; + + .modal-title { + padding: 0 3rem; + + .button { + font-size: 0.86rem; + } + } + + .modal-content, + .modal-body { + max-height: calc(100vh - 50px); + overflow-y: auto; + } + + .modal-body { + padding: 1rem 3rem; + text-align: left; + + + + pre { + border: 1px solid $lightest-grey; + } + } + + .modal-footer { + padding: 1rem 3rem; + + .button { + margin: 0; + } + } + + &--visual { + .modal-dialog { + max-width: none; + width: 75%; + } + + .modal-header { + padding: 0; + + .header { + width: 100%; + color: white; + padding: .5em 1em; + display: flex; + justify-content: space-between; + cursor: pointer; + } + } + } + } } /*.modal { diff --git a/app/src/css/modules/_table.scss b/app/src/css/modules/_table.scss index 34c6b89ef..04f1f9916 100644 --- a/app/src/css/modules/_table.scss +++ b/app/src/css/modules/_table.scss @@ -58,6 +58,10 @@ display: flex; flex-wrap: wrap; align-items: flex-end; + + &.no-actions { + align-items: center; + } } .list-actions { @@ -108,6 +112,29 @@ } } +.error__report, .report__table { + .Collapsible { + display: flex; + flex-direction: column; + + .Collapsible__trigger { + order: 1; + } + } +} + +.report__table { + .Collapsible__trigger { + align-self: flex-start; + cursor: pointer; + margin: 1em 0; + } +} + +.Collapsible__contentInner { + max-width: 230px; +} + // Overview Number Card Group // .overview-num__wrapper { diff --git a/app/src/css/vendor/bootstrap/_components.scss b/app/src/css/vendor/bootstrap/_components.scss index 7dc846eab..6e1605367 100644 --- a/app/src/css/vendor/bootstrap/_components.scss +++ b/app/src/css/vendor/bootstrap/_components.scss @@ -8,6 +8,7 @@ and behaviors for our Cumulus Dashboard components @import "~bootstrap/scss/_alert"; +@import "~bootstrap/scss/_badge"; @import "~bootstrap/scss/_breadcrumb"; @import "~bootstrap/scss/_card"; @import "~bootstrap/scss/_close"; diff --git a/app/src/css/vendor/datetimepicker/_dtpoverrides.scss b/app/src/css/vendor/datetimepicker/_dtpoverrides.scss index fd2824723..16273b6ff 100644 --- a/app/src/css/vendor/datetimepicker/_dtpoverrides.scss +++ b/app/src/css/vendor/datetimepicker/_dtpoverrides.scss @@ -7,9 +7,13 @@ these are the styling imports for that dependency. Here are our styling overrides */ -.react-datetime-picker__clear-button { +/*.react-datetime-picker__clear-button { content: ""; display: none; +}*/ + +.react-datetime-picker__inputGroup, .react-calendar__month-view__days, .react-calendar__month-view__weekdays{ + color: $dark-grey; } .react-datetime-picker__calendar.react-datetime-picker__calendar--open, .react-datetime-picker__clock.react-datetime-picker__clock--open{ @@ -17,6 +21,7 @@ styling overrides bottom: unset; left: 0px; right: unset; + z-index: 99; } .react-datetime-picker__wrapper { @@ -46,7 +51,45 @@ styling overrides color: $black !important; } -/* .react-datetime-picker__calendar .react-calendar, .react-datetime-picker__clock.react-datetime-picker__clock--open, .react-datetime-picker__calendar-button.react-datetime-picker__button { +.react-datetime-picker__clock.react-datetime-picker__clock--open { visibility: hidden; display: none; -} */ \ No newline at end of file +} + +.react-datetime-picker__wrapper { + border: 1px solid lighten($lightest-grey, 4%) !important; + box-shadow: $shadow__default; + background-color: $white; + border-radius: .5em; +} + +/*.react-datetime-picker__inputGroup__input option:first-child { + &:before { + color: $ocean-blue; + position: absolute; + font-family: 'FontAwesome'; + content: '\f0d7'; + font-weight: 900; + width: 14px; + height: 14px; + background-size: 14px 14px; + display: block; + right: .8em; + top: .5em; + } + } + + .react-datetime-picker__inputGroup__leadingZero { + font-size: 1.2em; +} + + .react-datetime-picker__inputGroup__input--hasLeadingZero { + margin-left: 0px; + padding-left: 1px; +}*/ + +/*.react-datetime-picker__inputGroup__input.react-datetime-picker__inputGroup__month, +.react-datetime-picker__inputGroup__input.react-datetime-picker__inputGroup__day { + width: 26px !important; +}*/ + diff --git a/app/src/js/App.js b/app/src/js/App.js index e783826f2..1d16b7d45 100644 --- a/app/src/js/App.js +++ b/app/src/js/App.js @@ -1,16 +1,13 @@ 'use strict'; import React, { Component } from 'react'; import { Provider } from 'react-redux'; -import configureStore, { history } from './store/configureStore'; +import ourConfigureStore, { history } from './store/configureStore'; import { Route, Redirect, Switch } from 'react-router-dom'; import { ConnectedRouter } from 'connected-react-router'; -// import { useScroll as notHookUseScroll } from 'react-router-scroll-4'; // Fontawesome Icons Library import { library, dom } from '@fortawesome/fontawesome-svg-core'; -import { faSignOutAlt, faSearch, faSync, faPlus, faInfoCircle, faTimesCircle, faSave, faCalendar, faExpand, faCompress, faClock, faCaretDown, faChevronDown, faSort, faSortDown, faSortUp, faArrowAltCircleLeft, faArrowAltCircleRight, faArrowAltCircleDown, faArrowAltCircleUp, faArrowRight, faCopy, faEdit, faArchive, faLaptopCode, faServer, faHdd, faExternalLinkSquareAlt, faToggleOn, faToggleOff, faExclamationTriangle, faCoins, faCheckCircle, faCircle } from '@fortawesome/free-solid-svg-icons'; -library.add(faSignOutAlt, faSearch, faSync, faPlus, faInfoCircle, faTimesCircle, faSave, faCalendar, faExpand, faCompress, faClock, faCaretDown, faSort, faChevronDown, faSortDown, faSortUp, faArrowAltCircleLeft, faArrowAltCircleRight, faArrowAltCircleDown, faArrowAltCircleUp, faArrowRight, faCopy, faEdit, faArchive, faLaptopCode, faServer, faHdd, faExternalLinkSquareAlt, faToggleOn, faToggleOff, faExclamationTriangle, faCoins, faCheckCircle, faCircle); -dom.watch(); +import { faSignOutAlt, faSearch, faSync, faRedo, faPlus, faInfoCircle, faTimesCircle, faSave, faCalendar, faExpand, faCompress, faClock, faCaretDown, faChevronDown, faSort, faSortDown, faSortUp, faArrowAltCircleLeft, faArrowAltCircleRight, faArrowAltCircleDown, faArrowAltCircleUp, faArrowRight, faCopy, faEdit, faArchive, faLaptopCode, faServer, faHdd, faExternalLinkSquareAlt, faToggleOn, faToggleOff, faExclamationTriangle, faCoins, faCheckCircle, faCircle } from '@fortawesome/free-solid-svg-icons'; // Authorization & Error Handling // import ErrorBoundary from './components/Errors/ErrorBoundary'; @@ -31,6 +28,8 @@ import Rules from './components/Rules'; import ReconciliationReports from './components/ReconciliationReports'; import config from './config'; +library.add(faSignOutAlt, faSearch, faSync, faRedo, faPlus, faInfoCircle, faTimesCircle, faSave, faCalendar, faExpand, faCompress, faClock, faCaretDown, faSort, faChevronDown, faSortDown, faSortUp, faArrowAltCircleLeft, faArrowAltCircleRight, faArrowAltCircleDown, faArrowAltCircleUp, faArrowRight, faCopy, faEdit, faArchive, faLaptopCode, faServer, faHdd, faExternalLinkSquareAlt, faToggleOn, faToggleOff, faExclamationTriangle, faCoins, faCheckCircle, faCircle); +dom.watch(); console.log.apply(console, config.consoleMessage); console.log('Environment', config.environment); @@ -61,7 +60,7 @@ class App extends Component { constructor (props) { super(props); this.state = {}; - this.store = configureStore({}); + this.store = ourConfigureStore({}); this.isLoggedIn = this.isLoggedIn.bind(this); } diff --git a/app/src/js/actions/helpers.js b/app/src/js/actions/helpers.js index 1b4496a75..426f4e296 100644 --- a/app/src/js/actions/helpers.js +++ b/app/src/js/actions/helpers.js @@ -1,5 +1,4 @@ 'use strict'; -import url from 'url'; import { get as getProperty } from 'object-path'; import _config from '../config'; @@ -15,14 +14,14 @@ export const formatError = (response = {}, body) => { export const getErrorMessage = (response) => { const { body } = response; - const errorMessage = body && body.errorMessage || body && body.message || body && body.detail; + const errorMessage = (body && body.errorMessage) || (body && body.message) || (body && body.detail); if (errorMessage) return errorMessage; return formatError(response, body); }; export const addRequestAuthorization = (config, state) => { - let token = getProperty(state, 'api.tokens.token'); + const token = getProperty(state, 'api.tokens.token'); if (token) { config.headers = Object.assign({}, config.headers, { Authorization: `Bearer ${token}` @@ -44,7 +43,7 @@ export const configureRequest = (params = {}) => { if (typeof config.path !== 'string') { throw new Error('Path must be a string'); } - config.url = url.resolve(_config.apiRoot, config.path); + config.url = new URL(config.path, _config.apiRoot).href; } const defaultRequestConfig = { diff --git a/app/src/js/actions/index.js b/app/src/js/actions/index.js index 0f0cabc45..8b107e054 100644 --- a/app/src/js/actions/index.js +++ b/app/src/js/actions/index.js @@ -1,12 +1,12 @@ 'use strict'; import compareVersions from 'compare-versions'; -import url from 'url'; import { get as getProperty } from 'object-path'; import requestPromise from 'request-promise'; import { history } from '../store/configureStore'; import { CMR } from '@cumulus/cmrjs'; -import clonedeep from 'lodash.clonedeep'; +import isEmpty from 'lodash.isempty'; +import cloneDeep from 'lodash.clonedeep'; import { configureRequest } from './helpers'; import _config from '../config'; @@ -26,12 +26,10 @@ const { showDistributionAPIMetrics, showTeaMetrics, apiRoot: root, - pageLimit, + defaultPageLimit, minCompatibleApiVersion } = _config; -const millisecondsPerDay = 24 * 60 * 60 * 1000; - /** * match MMT to CMR environment. * @param {string} env - cmr environment defaults to 'SIT' @@ -49,7 +47,7 @@ export const refreshAccessToken = (token) => { const requestConfig = configureRequest({ method: 'POST', - url: url.resolve(root, 'refresh'), + url: new URL('refresh', root).href, body: { token }, // make sure request failures are sent to .catch() simple: true @@ -85,7 +83,7 @@ export const getCollection = (name, version) => ({ [CALL_API]: { type: types.COLLECTION, method: 'GET', - id: getCollectionId({name, version}), + id: getCollectionId({ name, version }), path: `collections?name=${name}&version=${version}` } }); @@ -94,7 +92,7 @@ export const getApiVersion = () => { return (dispatch) => { const config = configureRequest({ method: 'GET', - url: url.resolve(root, 'version'), + url: new URL('version', root).href, // make sure request failures are sent to .catch() simple: true }); @@ -129,18 +127,24 @@ export const checkApiVersion = () => { }; }; -export const listCollections = (options) => { +export const listCollections = (options = {}) => { + const { listAll = false, getMMT = true, ...queryOptions } = options; return (dispatch, getState) => { - const timeFilters = fetchCurrentTimeFilters(getState().datepicker); + const timeFilters = listAll ? {} : fetchCurrentTimeFilters(getState().datepicker); + const urlPath = `collections${isEmpty(timeFilters) || listAll ? '' : '/active'}`; return dispatch({ [CALL_API]: { type: types.COLLECTIONS, method: 'GET', id: null, - url: url.resolve(root, 'collections'), - qs: Object.assign({ limit: pageLimit }, options, timeFilters) + url: new URL(urlPath, root).href, + qs: Object.assign({ limit: defaultPageLimit }, queryOptions, timeFilters) + } + }).then(() => { + if (getMMT) { + dispatch(getMMTLinks()); } - }).then(() => dispatch(getMMTLinks())); + }); }; }; @@ -154,12 +158,13 @@ export const createCollection = (payload) => ({ } }); -export const updateCollection = (payload) => ({ +// include the option to specify the name and version of the collection to update in case they differ in the payload +export const updateCollection = (payload, name, version) => ({ [CALL_API]: { type: types.UPDATE_COLLECTION, method: 'PUT', - id: getCollectionId(payload), - path: `collections/${payload.name}/${payload.version}`, + id: (name && version) ? getCollectionId({ name, version }) : getCollectionId(payload), + path: `collections/${name || payload.name}/${version || payload.version}`, body: payload } }); @@ -170,7 +175,7 @@ export const deleteCollection = (name, version) => ({ [CALL_API]: { type: types.COLLECTION_DELETE, method: 'DELETE', - id: getCollectionId({name, version}), + id: getCollectionId({ name, version }), path: `collections/${name}/${version}` } }); @@ -226,8 +231,9 @@ export const getMMTLinkFromCmr = (collection, getState) => { } = getState(); if (!cmrProvider || !cmrEnvironment) { - return Promise.reject('Missing Cumulus Instance Metadata in state.' + - ' Make sure a call to getCumulusInstanceMetadata is dispatched.'); + return Promise.reject( + new Error('Missing Cumulus Instance Metadata in state.' + + ' Make sure a call to getCumulusInstanceMetadata is dispatched.')); } if (getCollectionId(collection) in mmtLinks) { @@ -275,9 +281,10 @@ export const listGranules = (options) => { type: types.GRANULES, method: 'GET', id: null, - url: url.resolve(root, 'granules'), - qs: Object.assign({ limit: pageLimit }, options, timeFilters) - }}); + url: new URL('granules', root).href, + qs: Object.assign({ limit: defaultPageLimit }, options, timeFilters) + } + }); }; }; @@ -297,7 +304,7 @@ export const applyWorkflowToCollection = (name, version, workflow) => ({ [CALL_API]: { type: types.COLLECTION_APPLYWORKFLOW, method: 'PUT', - id: getCollectionId({name, version}), + id: getCollectionId({ name, version }), path: `collections/${name}/${version}`, body: { action: 'applyWorkflow', @@ -427,7 +434,7 @@ export const getGranuleCSV = (options) => ({ [CALL_API]: { type: types.GRANULE_CSV, method: 'GET', - url: url.resolve(root, 'granule-csv') + url: new URL('granule-csv', root).href } }); @@ -435,7 +442,7 @@ export const getOptionsCollectionName = (options) => ({ [CALL_API]: { type: types.OPTIONS_COLLECTIONNAME, method: 'GET', - url: url.resolve(root, 'collections'), + url: new URL('collections', root).href, qs: { limit: 100, fields: 'name,version' } } }); @@ -447,103 +454,122 @@ export const getStats = (options) => { [CALL_API]: { type: types.STATS, method: 'GET', - url: url.resolve(root, 'stats'), - qs: {...options, ...timeFilters} + url: new URL('stats', root).href, + qs: { ...options, ...timeFilters } } }); }; }; export const getDistApiGatewayMetrics = (cumulusInstanceMeta) => { - const stackName = cumulusInstanceMeta.stackName; - const now = Date.now(); - const twentyFourHoursAgo = now - millisecondsPerDay; if (!esRoot) return { type: types.NOOP }; - return { - [CALL_API]: { - type: types.DIST_APIGATEWAY, - skipAuth: true, - method: 'POST', - url: `${esRoot}/_search/`, - headers: authHeader(), - body: JSON.parse(apiGatewaySearchTemplate(stackName, twentyFourHoursAgo, now)) - } + return (dispatch, getState) => { + const stackName = cumulusInstanceMeta.stackName; + const timeFilters = fetchCurrentTimeFilters(getState().datepicker); + const endTime = timeFilters.timestamp__to || Date.now(); + const startTime = timeFilters.timestamp__from || 0; + return dispatch({ + [CALL_API]: { + type: types.DIST_APIGATEWAY, + skipAuth: true, + method: 'POST', + url: `${esRoot}/_search/`, + headers: authHeader(), + body: JSON.parse(apiGatewaySearchTemplate(stackName, startTime, endTime)) + } + }); }; }; export const getDistApiLambdaMetrics = (cumulusInstanceMeta) => { - const stackName = cumulusInstanceMeta.stackName; - const now = Date.now(); - const twentyFourHoursAgo = now - millisecondsPerDay; if (!esRoot) return { type: types.NOOP }; - if (!showDistributionAPIMetrics) return {type: types.NOOP}; - return { - [CALL_API]: { - type: types.DIST_API_LAMBDA, - skipAuth: true, - method: 'POST', - url: `${esRoot}/_search/`, - headers: authHeader(), - body: JSON.parse(apiLambdaSearchTemplate(stackName, twentyFourHoursAgo, now)) - } + if (!showDistributionAPIMetrics) return { type: types.NOOP }; + return (dispatch, getState) => { + const stackName = cumulusInstanceMeta.stackName; + const timeFilters = fetchCurrentTimeFilters(getState().datepicker); + const endTime = timeFilters.timestamp__to || Date.now(); + const startTime = timeFilters.timestamp__from || 0; + return dispatch({ + [CALL_API]: { + type: types.DIST_API_LAMBDA, + skipAuth: true, + method: 'POST', + url: `${esRoot}/_search/`, + headers: authHeader(), + body: JSON.parse(apiLambdaSearchTemplate(stackName, startTime, endTime)) + } + }); }; }; export const getTEALambdaMetrics = (cumulusInstanceMeta) => { - const stackName = cumulusInstanceMeta.stackName; - const now = Date.now(); - const twentyFourHoursAgo = now - millisecondsPerDay; if (!esRoot) return { type: types.NOOP }; if (!showTeaMetrics) return { type: types.NOOP }; - return { - [CALL_API]: { - type: types.DIST_TEA_LAMBDA, - skipAuth: true, - method: 'POST', - url: `${esRoot}/_search/`, - headers: authHeader(), - body: JSON.parse(teaLambdaSearchTemplate(stackName, twentyFourHoursAgo, now)) - } + return (dispatch, getState) => { + const stackName = cumulusInstanceMeta.stackName; + const timeFilters = fetchCurrentTimeFilters(getState().datepicker); + const endTime = timeFilters.timestamp__to || Date.now(); + const startTime = timeFilters.timestamp__from || 0; + return dispatch({ + [CALL_API]: { + type: types.DIST_TEA_LAMBDA, + skipAuth: true, + method: 'POST', + url: `${esRoot}/_search/`, + headers: authHeader(), + body: JSON.parse(teaLambdaSearchTemplate(stackName, startTime, endTime)) + } + }); }; }; export const getDistS3AccessMetrics = (cumulusInstanceMeta) => { - const stackName = cumulusInstanceMeta.stackName; - const now = Date.now(); - const twentyFourHoursAgo = now - millisecondsPerDay; if (!esRoot) return { type: types.NOOP }; - return { - [CALL_API]: { - type: types.DIST_S3ACCESS, - skipAuth: true, - method: 'POST', - url: `${esRoot}/_search/`, - headers: authHeader(), - body: JSON.parse(s3AccessSearchTemplate(stackName, twentyFourHoursAgo, now)) - } + return (dispatch, getState) => { + const stackName = cumulusInstanceMeta.stackName; + const timeFilters = fetchCurrentTimeFilters(getState().datepicker); + const endTime = timeFilters.timestamp__to || Date.now(); + const startTime = timeFilters.timestamp__from || 0; + return dispatch({ + [CALL_API]: { + type: types.DIST_S3ACCESS, + skipAuth: true, + method: 'POST', + url: `${esRoot}/_search/`, + headers: authHeader(), + body: JSON.parse(s3AccessSearchTemplate(stackName, startTime, endTime)) + } + }); }; }; // count queries *must* include type and field properties. -export const getCount = (options) => ({ - [CALL_API]: { - type: types.COUNT, - method: 'GET', - id: null, - url: url.resolve(root, 'stats/aggregate'), - qs: Object.assign({ type: 'must-include-type', field: 'status' }, options) - } -}); +export const getCount = (options) => { + return (dispatch, getState) => { + const timeFilters = fetchCurrentTimeFilters(getState().datepicker); + return dispatch({ + [CALL_API]: { + type: types.COUNT, + method: 'GET', + id: null, + url: new URL('stats/aggregate', root).href, + qs: Object.assign({ type: 'must-include-type', field: 'status' }, options, timeFilters) + } + }); + }; +}; export const listPdrs = (options) => { return (dispatch, getState) => { const timeFilters = fetchCurrentTimeFilters(getState().datepicker); - return dispatch({[CALL_API]: { - type: types.PDRS, - method: 'GET', - url: url.resolve(root, 'pdrs'), - qs: Object.assign({ limit: pageLimit }, options, timeFilters) - }}); + return dispatch({ + [CALL_API]: { + type: types.PDRS, + method: 'GET', + url: new URL('pdrs', root).href, + qs: Object.assign({ limit: defaultPageLimit }, options, timeFilters) + } + }); }; }; @@ -561,15 +587,18 @@ export const clearPdrsSearch = () => ({ type: types.CLEAR_PDRS_SEARCH }); export const filterPdrs = (param) => ({ type: types.FILTER_PDRS, param: param }); export const clearPdrsFilter = (paramKey) => ({ type: types.CLEAR_PDRS_FILTER, paramKey: paramKey }); -export const listProviders = (options) => { +export const listProviders = (options = {}) => { + const { listAll = false, ...queryOptions } = options; return (dispatch, getState) => { - const timeFilters = fetchCurrentTimeFilters(getState().datepicker); - return dispatch({[CALL_API]: { - type: types.PROVIDERS, - method: 'GET', - url: url.resolve(root, 'providers'), - qs: Object.assign({ limit: pageLimit }, options, timeFilters) - }}); + const timeFilters = listAll ? {} : fetchCurrentTimeFilters(getState().datepicker); + return dispatch({ + [CALL_API]: { + type: types.PROVIDERS, + method: 'GET', + url: new URL('providers', root).href, + qs: Object.assign({ limit: defaultPageLimit }, queryOptions, timeFilters) + } + }); }; }; @@ -577,7 +606,7 @@ export const getOptionsProviderGroup = () => ({ [CALL_API]: { type: types.OPTIONS_PROVIDERGROUP, method: 'GET', - url: url.resolve(root, 'providers'), + url: new URL('providers', root).href, qs: { limit: 100, fields: 'providerName' } } }); @@ -643,8 +672,8 @@ export const getLogs = (options) => { [CALL_API]: { type: types.LOGS, method: 'GET', - url: url.resolve(root, 'logs'), - qs: Object.assign({limit: 100}, options, timeFilters) + url: new URL('logs', root).href, + qs: Object.assign({ limit: 100 }, options, timeFilters) } }); }; @@ -664,7 +693,7 @@ export const login = (token) => ({ type: types.LOGIN, id: 'auth', method: 'GET', - url: url.resolve(root, 'granules'), + url: new URL('granules', root).href, qs: { limit: 1, fields: 'granuleId' }, skipAuth: true, headers: { @@ -680,7 +709,7 @@ export const deleteToken = () => { const requestConfig = configureRequest({ method: 'DELETE', - url: url.resolve(root, `tokenDelete/${token}`) + url: new URL(`tokenDelete/${token}`, root).href }); return requestPromise(requestConfig) .finally(() => dispatch({ type: types.DELETE_TOKEN })); @@ -707,16 +736,21 @@ export const listWorkflows = (options) => ({ [CALL_API]: { type: types.WORKFLOWS, method: 'GET', - url: url.resolve(root, 'workflows'), - qs: Object.assign({ limit: pageLimit }, options) + url: new URL('workflows', root).href, + qs: Object.assign({ limit: defaultPageLimit }, options) } }); +export const searchWorkflows = (searchString) => ({ type: types.SEARCH_WORKFLOWS, searchString }); +export const clearWorkflowsSearch = () => ({ type: types.CLEAR_WORKFLOWS_SEARCH }); + +export const searchExecutionEvents = (searchString) => ({ type: types.SEARCH_EXECUTION_EVENTS, searchString }); +export const clearExecutionEventsSearch = () => ({ type: types.CLEAR_EXECUTION_EVENTS_SEARCH }); export const getExecutionStatus = (arn) => ({ [CALL_API]: { type: types.EXECUTION_STATUS, method: 'GET', - url: url.resolve(root, 'executions/status/' + arn) + url: new URL('executions/status/' + arn, root).href } }); @@ -724,19 +758,21 @@ export const getExecutionLogs = (executionName) => ({ [CALL_API]: { type: types.EXECUTION_LOGS, method: 'GET', - url: url.resolve(root, 'logs/' + executionName) + url: new URL('logs/' + executionName, root).href } }); export const listExecutions = (options) => { return (dispatch, getState) => { const timeFilters = fetchCurrentTimeFilters(getState().datepicker); - return dispatch({[CALL_API]: { - type: types.EXECUTIONS, - method: 'GET', - url: url.resolve(root, 'executions'), - qs: Object.assign({ limit: pageLimit }, options, timeFilters) - }}); + return dispatch({ + [CALL_API]: { + type: types.EXECUTIONS, + method: 'GET', + url: new URL('executions', root).href, + qs: Object.assign({ limit: defaultPageLimit }, options, timeFilters) + } + }); }; }; @@ -752,8 +788,8 @@ export const listOperations = (options) => { [CALL_API]: { type: types.OPERATIONS, method: 'GET', - url: url.resolve(root, 'asyncOperations'), - qs: Object.assign({ limit: pageLimit }, options, timeFilters) + url: new URL('asyncOperations', root).href, + qs: Object.assign({ limit: defaultPageLimit }, options, timeFilters) } }); }; @@ -780,8 +816,8 @@ export const listRules = (options) => { [CALL_API]: { type: types.RULES, method: 'GET', - url: url.resolve(root, 'rules'), - qs: Object.assign({ limit: pageLimit }, options, timeFilters) + url: new URL('rules', root).href, + qs: Object.assign({ limit: defaultPageLimit }, options, timeFilters) } }); }; @@ -808,9 +844,9 @@ export const updateRule = (payload) => ({ export const clearUpdateRule = (ruleName) => ({ type: types.UPDATE_RULE_CLEAR, id: ruleName }); -export const createRule = (payload) => ({ +export const createRule = (name, payload) => ({ [CALL_API]: { - id: payload.name, + id: name, type: types.NEW_RULE, method: 'POST', path: 'rules', @@ -828,11 +864,7 @@ export const deleteRule = (ruleName) => ({ }); export const enableRule = (payload) => { - const rule = clonedeep(payload); - - if (!rule.rule.value) { - rule.rule.value = ''; - } + const rule = cloneDeep(payload); return { [CALL_API]: { @@ -849,11 +881,7 @@ export const enableRule = (payload) => { }; export const disableRule = (payload) => { - const rule = clonedeep(payload); - - if (!rule.rule.value) { - rule.rule.value = ''; - } + const rule = cloneDeep(payload); return { [CALL_API]: { @@ -882,12 +910,17 @@ export const rerunRule = (payload) => ({ } }); +export const searchRules = (prefix) => ({ type: types.SEARCH_RULES, prefix: prefix }); +export const clearRulesSearch = () => ({ type: types.CLEAR_RULES_SEARCH }); +export const filterRules = (param) => ({ type: types.FILTER_RULES, param: param }); +export const clearRulesFilter = (paramKey) => ({ type: types.CLEAR_RULES_FILTER, paramKey: paramKey }); + export const listReconciliationReports = (options) => ({ [CALL_API]: { type: types.RECONCILIATIONS, method: 'GET', - url: url.resolve(root, 'reconciliationReports'), - qs: Object.assign({ limit: pageLimit }, options) + url: new URL('reconciliationReports', root).href, + qs: Object.assign({ limit: defaultPageLimit }, options) } }); diff --git a/app/src/js/actions/types.js b/app/src/js/actions/types.js index 28177e825..54397590e 100644 --- a/app/src/js/actions/types.js +++ b/app/src/js/actions/types.js @@ -141,6 +141,8 @@ export const CLEAR_PROVIDERS_FILTER = 'CLEAR_PROVIDERS_FILTER'; export const WORKFLOWS = 'WORKFLOWS'; export const WORKFLOWS_INFLIGHT = 'WORKFLOWS_INFLIGHT'; export const WORKFLOWS_ERROR = 'WORKFLOWS_ERROR'; +export const SEARCH_WORKFLOWS = 'SEARCH_WORKFLOWS'; +export const CLEAR_WORKFLOWS_SEARCH = 'CLEAR_WORKFLOWS_SEARCH'; // Logs export const LOGS = 'LOGS'; export const LOGS_INFLIGHT = 'LOGS_INFLIGHT'; @@ -163,6 +165,9 @@ export const FILTER_EXECUTIONS = 'FILTER_EXECUTIONS'; export const CLEAR_EXECUTIONS_FILTER = 'CLEAR_EXECUTIONS_FILTER'; export const SEARCH_EXECUTIONS = 'SEARCH_EXECUTIONS'; export const CLEAR_EXECUTIONS_SEARCH = 'CLEAR_EXECUTIONS_SEARCH'; +export const SEARCH_EXECUTION_EVENTS = 'SEARCH_EXECUTION_EVENTS'; +export const CLEAR_EXECUTION_EVENTS_SEARCH = 'CLEAR_EXECUTION_EVENTS_SEARCH'; + // Operations export const OPERATIONS = 'OPERATIONS'; export const OPERATIONS_INFLIGHT = 'OPERATIONS_INFLIGHT'; @@ -200,6 +205,10 @@ export const RULE_ENABLE_ERROR = 'RULE_ENABLE_ERROR'; export const RULE_DISABLE = 'RULE_DISABLE'; export const RULE_DISABLE_INFLIGHT = 'RULE_DISABLE_INFLIGHT'; export const RULE_DISABLE_ERROR = 'RULE_DISABLE_ERROR'; +export const SEARCH_RULES = 'SEARCH_RULES'; +export const CLEAR_RULES_SEARCH = 'CLEAR_RULES_SEARCH'; +export const FILTER_RULES = 'FILTER_RULES'; +export const CLEAR_RULES_FILTER = 'CLEAR_RULES_FILTER'; // Reports export const RECONCILIATION = 'RECONCILIATION'; export const RECONCILIATION_INFLIGHT = 'RECONCILIATION_INFLIGHT'; diff --git a/app/src/js/components/Add/add.js b/app/src/js/components/Add/add.js index 580d13f90..cf50d463e 100644 --- a/app/src/js/components/Add/add.js +++ b/app/src/js/components/Add/add.js @@ -1,4 +1,5 @@ 'use strict'; + import path from 'path'; import React from 'react'; import PropTypes from 'prop-types'; @@ -10,12 +11,13 @@ import Schema from '../FormSchema/schema'; import Loading from '../LoadingIndicator/loading-indicator'; import _config from '../../config'; import { strings } from '../locale'; +import { window } from '../../utils/browser'; const { updateDelay } = _config; -class AddCollection extends React.Component { - constructor () { - super(); +class AddRecord extends React.Component { + constructor (props) { + super(props); this.state = { pk: null }; @@ -31,15 +33,17 @@ class AddCollection extends React.Component { const { pk } = this.state; const { history, baseRoute } = prevProps; const status = get(this.props.state, ['created', pk, 'status']); + if (status === 'success') { return setTimeout(() => { history.push(path.join(baseRoute, pk)); + window.scrollTo(0, 0); }, updateDelay); } } navigateBack () { - this.props.history.push(this.props.baseRoute.split('/')[1]); + this.props.history.push(`/${this.props.baseRoute.split('/')[1]}`); } post (id, payload) { @@ -50,11 +54,13 @@ class AddCollection extends React.Component { validate, createRecord } = this.props; + if (attachMeta) { payload.createdAt = new Date().getTime(); - payload.updatedAt = new Date().getTime(); + payload.updatedAt = payload.createdAt; payload.changedBy = strings.dashboard; } + if (!validate || validate(payload)) { const pk = get(payload, primaryProperty); this.setState({ pk }, () => dispatch(createRecord(pk, payload))); @@ -64,35 +70,46 @@ class AddCollection extends React.Component { } render () { - const { title, state, schemaKey } = this.props; + const { data, title, state, schemaKey } = this.props; const { pk } = this.state; const record = pk ? get(state.created, pk, {}) : {}; const schema = this.props.schema[schemaKey]; + return ( -
-
-
-

{title}

+
+
+
+

{title}

- {schema ? : } + {schema ? ( + + ) : ( + + )}
); } } -AddCollection.propTypes = { +AddRecord.propTypes = { + data: PropTypes.object, schema: PropTypes.object, schemaKey: PropTypes.string, primaryProperty: PropTypes.string, title: PropTypes.string, + enums: PropTypes.objectOf(PropTypes.array), dispatch: PropTypes.func, state: PropTypes.object, @@ -102,9 +119,34 @@ AddCollection.propTypes = { attachMeta: PropTypes.bool, createRecord: PropTypes.func, - validate: PropTypes.func + validate: PropTypes.func, + + // Specifies schema properties to include on the form. Each element in this + // array may be either a string that specifies the full path of the property + // within the schema (e.g., "collection.name" and "collection.version") or a + // regular expression (e.g., /^collection/). + include: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(RegExp)]) + ), + // Specifies schema properties to exclude from the form. Elements in this + // array are specified the same was as in the "include" array. However, + // exclusions are applied after inclusions, so a property that is included + // via the "include" array may be excluded by this array, preventing it from + // appearing on the form. + exclude: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(RegExp)]) + ) +}; + +Schema.defaultProps = { + // Exclude no schema properties + exclude: [], + // Include all schema properties + include: [/.+/] }; -export default withRouter(connect(state => ({ - schema: state.schema -}))(AddCollection)); +export default withRouter( + connect((state) => ({ + schema: state.schema + }))(AddRecord) +); diff --git a/app/src/js/components/AddRaw/add-raw.js b/app/src/js/components/AddRaw/add-raw.js index 0a12bfacc..ae5701794 100644 --- a/app/src/js/components/AddRaw/add-raw.js +++ b/app/src/js/components/AddRaw/add-raw.js @@ -7,11 +7,11 @@ import { get } from 'object-path'; import { displayCase } from '../../utils/format'; import _config from '../../config'; -const { updateDelay } = _config; - import TextArea from '../TextAreaForm/text-area'; import DefaultModal from '../Modal/modal'; +const { updateDelay } = _config; + const defaultState = { data: '', pk: null, diff --git a/app/src/js/components/ArbitraryList/arbitrary-list.js b/app/src/js/components/ArbitraryList/arbitrary-list.js index 7bb3ada42..d5dfe7102 100644 --- a/app/src/js/components/ArbitraryList/arbitrary-list.js +++ b/app/src/js/components/ArbitraryList/arbitrary-list.js @@ -20,19 +20,19 @@ class List extends React.Component { add (e) { e.preventDefault(); const value = this.props.value.slice(); - if (!value[value.length - 1]) return false; + if (!value[value.length - 1]) return; value.push(''); this.props.onChange(this.props.id, value); } remove (index) { - let value = this.props.value.slice(); + const value = this.props.value.slice(); value.splice(index, 1); this.props.onChange(this.props.id, value); } render () { - let { + const { label, value, error @@ -42,9 +42,9 @@ class List extends React.Component { const items = value.length ? value : ['']; return ( -
+
- {error} + {error && {error}}
    {items.map(this.renderItem)}
diff --git a/app/src/js/components/AsyncCommands/AsyncCommands.js b/app/src/js/components/AsyncCommands/AsyncCommands.js index da6fc4d79..e9835b982 100644 --- a/app/src/js/components/AsyncCommands/AsyncCommands.js +++ b/app/src/js/components/AsyncCommands/AsyncCommands.js @@ -5,16 +5,16 @@ import React from 'react'; import c from 'classnames'; import PropTypes from 'prop-types'; import Ellipsis from '../LoadingEllipsis/loading-ellipsis'; +import DefaultModal from '../Modal/modal'; import { preventDefault } from '../../utils/noop'; import _config from '../../config'; -import Modal from 'react-bootstrap/Modal'; const { updateDelay } = _config; class AsyncCommand extends React.Component { constructor () { super(); - this.state = { modal: false }; + this.state = { confirmModal: false }; this.buttonClass = this.buttonClass.bind(this); this.elementClass = this.elementClass.bind(this); this.handleClick = this.handleClick.bind(this); @@ -30,6 +30,7 @@ class AsyncCommand extends React.Component { ) { const timeout = isNaN(prevProps.successTimeout) ? updateDelay : prevProps.successTimeout; setTimeout(prevProps.success, timeout); + this.setState({ successModal: true }); // eslint-disable-line react/no-did-update-set-state } else if ( prevProps.status === 'inflight' && this.props.status === 'error' && @@ -60,7 +61,7 @@ class AsyncCommand extends React.Component { handleClick (e) { e.preventDefault(); if (this.props.confirmAction) { - this.setState({ modal: true }); + this.setState({ confirmModal: true }); } else if (this.props.status !== 'inflight' && !this.props.disabled) { // prevent duplicate action if the action is already inflight. this.props.action(); @@ -69,16 +70,17 @@ class AsyncCommand extends React.Component { confirm () { this.props.action(); - this.setState({ modal: false }); + this.setState({ confirmModal: false }); + if (this.props.status === 'success') this.setState({ successModal: true }); } cancel () { - this.setState({ modal: false }); + this.setState({ confirmModal: false, successModal: false }); } render () { - const { status, text, confirmText, confirmOptions } = this.props; - const { modal } = this.state; + const { status, text, confirmText, confirmOptions, showSuccessModal, postActionText } = this.props; + const { confirmModal, successModal } = this.state; const inflight = status === 'inflight'; const element = this.props.element || 'button'; const props = { @@ -96,44 +98,41 @@ class AsyncCommand extends React.Component { return (
{ button } - { modal ?
: null } + { confirmModal ?
: null }
- { modal ? ( - - - - -
- { confirmOptions ? (confirmOptions).map(option => -
- {option} -
-
- ) : null } -

{confirmText}

-
-
- - - - -
- ) : null } + + { confirmOptions ? (confirmOptions).map(option => +
+ {option} +
+
+ ) : null } +

{confirmText}

+
+ )} + showModal={confirmModal} + /> +
); @@ -153,6 +152,8 @@ AsyncCommand.propTypes = { confirmAction: PropTypes.bool, confirmText: PropTypes.string, confirmOptions: PropTypes.array, + showSuccessModal: PropTypes.bool, + postActionText: PropTypes.string, href: PropTypes.string }; diff --git a/app/src/js/components/BatchAsyncCommands/BatchAsyncCommands.js b/app/src/js/components/BatchAsyncCommands/BatchAsyncCommands.js index 9c99a4444..c75988db3 100644 --- a/app/src/js/components/BatchAsyncCommands/BatchAsyncCommands.js +++ b/app/src/js/components/BatchAsyncCommands/BatchAsyncCommands.js @@ -1,26 +1,37 @@ -/* This will eventually just be a general batchasync */ -/* For Deleting Multiple Collections - The Modal function (later other modals): Need to copy logic from here and implement in BatchDeleteCollectionModal.js */ 'use strict'; import React from 'react'; import PropTypes from 'prop-types'; +import { withRouter } from 'react-router'; import queue from 'stubborn-queue'; import AsyncCommand from '../AsyncCommands/AsyncCommands'; import _config from '../../config'; -import Modal from 'react-bootstrap/Modal'; +import DefaultModal from '../Modal/modal'; const { updateDelay } = _config; const CONCURRENCY = 3; const IN_PROGRESS = 'Processing...'; -class BatchCommand extends React.Component { +/** BatchCommand + * @description a reusable component for implementing batch async commands. For example: bulk delete, update, etc. + * @param {object} props + * @param {function} props.getModalOptions This is the primary function used change the contents of the modal. + * It returns a modalOptions object which is passed as props to + * Without this prop, by default, an empty modal will open with a progress bar running as the batch commands execute. + * When using this function, one conditionally display content based on whether it should be displayed after confirm is clicked 'isOnModalConfirm: true', + * after the action has completed 'isOnModalComplete: true', or neither (e.g. after the initial button that triggered the modal is clicked). + * All those scenarios can display different content for the modal based on logic setup within getModalOptions. + */ + +export class BatchCommand extends React.Component { constructor () { super(); this.state = { callbacks: {}, activeModal: false, completed: 0, - status: null + status: null, + modalOptions: null }; this.isRunning = false; this.confirm = this.confirm.bind(this); @@ -32,6 +43,11 @@ class BatchCommand extends React.Component { this.cleanup = this.cleanup.bind(this); this.isInflight = this.isInflight.bind(this); this.handleClick = this.handleClick.bind(this); + this.closeModal = this.closeModal.bind(this); + } + + closeModal () { + this.setState({ activeModal: false }); } componentDidUpdate () { @@ -44,7 +60,7 @@ class BatchCommand extends React.Component { Object.keys(callbacks).forEach(id => { if (!state[id] || !callbacks[id]) return; else if (state[id].status === 'success') callbacks[id](null, id); - else if (state[id].status === 'error') callbacks[id]({error: state[id].error, id}); + else if (state[id].status === 'error') callbacks[id]({ error: state[id].error, id }); if (state[id].status === 'success' || state[id].status === 'error') { delete callbacks[id]; @@ -55,6 +71,22 @@ class BatchCommand extends React.Component { } confirm () { + const { selected, history, getModalOptions } = this.props; + if (typeof getModalOptions === 'function') { + const modalOptions = getModalOptions({ + selected, + history, + isOnModalConfirm: true, + isOnModalComplete: false, + closeModal: this.closeModal + }); + this.setState({ modalOptions }); + + // if we're replacing the onConfirm function, we don't want to continue with the current one + if (modalOptions.onConfirm) { + return; + } + } if (!this.isInflight()) this.start(); } @@ -64,13 +96,13 @@ class BatchCommand extends React.Component { } start () { - const { selection } = this.props; + const { selected } = this.props; // if we have inflight callbacks, don't allow further clicks - if (!Array.isArray(selection) || !selection.length || + if (!Array.isArray(selected) || !selected.length || this.isInflight()) return false; const q = queue(CONCURRENCY); - for (let i = 0; i < selection.length; ++i) { - q.add(this.initAction, selection[i]); + for (let i = 0; i < selected.length; ++i) { + q.add(this.initAction, selected[i]); } q.done(this.onComplete); } @@ -89,7 +121,7 @@ class BatchCommand extends React.Component { const delay = this.props.updateDelay ? this.props.updateDelay : updateDelay; // turn array of errors from queue into single error for ui const error = this.createErrorMessage(errors); - this.setState({status: (error ? 'error' : 'success')}); + this.setState({ status: (error ? 'error' : 'success') }); setTimeout(() => { this.cleanup(error, results); }, delay); @@ -98,15 +130,26 @@ class BatchCommand extends React.Component { // combine multiple errors into one createErrorMessage (errors) { if (!errors || !errors.length) return; - return `${errors.length} errors occurred: \n${errors.map((err) => err.error.toString()).join('\n')}`; + return `${errors.length} error(s) occurred: \n${errors.map((err) => err.error.toString()).join('\n')}`; } // call onSuccess and onError functions as needed cleanup (error, results) { - const { onSuccess, onError } = this.props; - this.setState({ activeModal: false, completed: 0, status: null }); + const { onSuccess, onError, getModalOptions, selected, history } = this.props; + this.setState({ completed: 0, status: null }); + if (typeof getModalOptions === 'function') { + const modalOptions = getModalOptions({ + history, + selected, + results, + error, + isOnModalComplete: true, + closeModal: this.closeModal + }); + this.setState({ modalOptions }); + } if (error && typeof onError === 'function') onError(error); - if (results && results.length && typeof onSuccess === 'function') onSuccess(results); + if (results && results.length && typeof onSuccess === 'function') onSuccess(results, error); } isInflight () { @@ -114,6 +157,15 @@ class BatchCommand extends React.Component { } handleClick () { + const { selected, history, getModalOptions } = this.props; + if (typeof getModalOptions === 'function') { + const modalOptions = getModalOptions({ + selected, + history, + closeModal: this.closeModal + }); + this.setState({ modalOptions }); + } if (this.props.confirm) { this.setState({ activeModal: true, completed: 0 }); } else this.start(); @@ -122,17 +174,17 @@ class BatchCommand extends React.Component { render () { const { text, - selection, + selected, className, confirm, confirmOptions } = this.props; - const { activeModal, completed, status } = this.state; - const todo = selection.length; + const { activeModal, completed, status, modalOptions } = this.state; + const todo = selected.length; const inflight = this.isInflight(); // show button as disabled when loading, and in the delay before we clean up. - const buttonDisabled = inflight || status; + const buttonClass = inflight || status ? 'button--disabled' : ''; const modalText = inflight ? IN_PROGRESS : !status ? confirm(todo) : status === 'success' ? 'Success!' : 'Error'; @@ -147,45 +199,37 @@ class BatchCommand extends React.Component { successTimeout={0} status={!activeModal && inflight ? 'inflight' : null} /> - { activeModal ?
: null } + { activeModal &&
}
- { activeModal ? ( - - -

{modalText}

- -
- { confirmOptions ? (confirmOptions).map(option => -
- {option} -
-
- ) : null } -
-
-
-
-
+ + {(!modalOptions || !modalOptions.children) && + (
+ {confirmOptions && (confirmOptions).map(option => +
+ {option} +
+
+ )} +
+
+
- - - - - - - ) : null} +
+
)} + {modalOptions && modalOptions.children} +
); @@ -197,13 +241,15 @@ BatchCommand.propTypes = { dispatch: PropTypes.func, state: PropTypes.object, text: PropTypes.string, - selection: PropTypes.array, + selected: PropTypes.array, className: PropTypes.string, onSuccess: PropTypes.func, onError: PropTypes.func, confirm: PropTypes.func, confirmOptions: PropTypes.array, - updateDelay: PropTypes.number + getModalOptions: PropTypes.func, + updateDelay: PropTypes.number, + history: PropTypes.object }; -export default BatchCommand; +export default withRouter(BatchCommand); diff --git a/app/src/js/components/Button/Button.scss b/app/src/js/components/Button/Button.scss deleted file mode 100644 index cf185c525..000000000 --- a/app/src/js/components/Button/Button.scss +++ /dev/null @@ -1,465 +0,0 @@ -.button { - position: relative; - padding: .65em 1.2em .65em 2.5em; - border-radius: 4px; - color: $white; - background-color: $ocean-blue; - font-weight: $base-font-regular; - text-align: center; - border: none; - transition: all 0.3s ease 0s; - -webkit-transition: all 0.3s; - -moz-transition: all 0.3s; - overflow: hidden; - input { - cursor: pointer; - border: none; - background-color: transparent; - padding: 0; - } - - &:visited { - color: $white; - } - - &:disabled{ - opacity: 0.6; - pointer-events: none; - } - - &:hover{ - background-color: $midnight-blue; - } - - &--oauth{ - padding: 1em; - } - - &--small{ - font-size: .86em; - padding: .4em 1.5em .3em 2.5em; - } - - &--large{ - padding: .7em 2em; - font-weight: $base-font-bold; - font-size: 1em; - } - - &--white, &--white:visited { - color: $midnight-blue; - background-color: $white; - - &:hover{ - color: $white; - background-color: $midnight-blue; - } - } - - &--green, &--green:visited { - background-color: $light-green; - - &:hover { - background-color: darken($light-green, 10%); - color: #fff; - border: 0; - } - } - - &--primary, &--primary{ - &:hover, &:visited { - background-color: darken($light-green, 10%); - color: $white; - border: 0; - } - } - - &--secondary, &--secondary{ - &:hover, &:visited { - background-color: $dolphin-grey; - } - } - - &--top{ - background-color: $light-green; - color: $white; - border: none; - border-radius: 4px; - padding: .65em 1.2em .65em 2.5em; - display: flex; - justify-content: center; - align-content: center; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f0aa'; - font-weight: 900; - left: 13px; - - } - - &:hover{ - &:hover { - background-color: darken($light-green, 5%); - } - } - } - - &--close{ - padding: .65em 1.2em .65em 1.2em; - } - - /*******ButtonGroup & Group CTA******/ - &--add { - background-color: $light-green; - position: relative; - - &:before { - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f067'; - font-weight: 900; - top: 4px; - left: 10px; - } - } - - &--cancel { - background-color: $dolphin-grey; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f057'; - font-weight: 900; - left: 10px; - - } - } - - &--cancel:hover:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f057'; - font-weight: 900; - visibility: hidden; - } - - &--edit { - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f044'; - font-weight: 900; - left: 10px; - - } - } - - &--delete { - background-color: $error-red; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f187'; - font-weight: 900; - left: 10px; - } - &:hover { - background-color: darken($error-red, 10%); - color: #fff; - border: 0; - } - } - - &--remove { - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f0a0'; - font-weight: 900; - left: 10px; - - } - } - - &--execute{ - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f5fc'; - font-weight: 900; - left: 10px; - - } - } - - &--reingest{ - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f233'; - font-weight: 900; - left: 10px; - - } - } - - &--remove, &--execute, &--reingest { - &:hover { - background-color: darken($light-green, 10%); - color: #fff; - border: 0; - } - } - - &--download{ - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f0ab'; - font-weight: 900; - left: 10px; - - } - } - - &--copy{ - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f0c5'; - font-weight: 900; - left: 10px; - - } - } - - &--confirm, &--submit{ - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f0c7'; - font-weight: 900; - left: 10px; - - } - } - - &--confirm:hover:before, &--submit:hover:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f0c7'; - font-weight: 900; - visibility: hidden; - } - - /*******Button Animations & Transitions ******/ - &--loading { - position: relative; - display: inline-block; - .spinner { - position: absolute; - top: 6px; - left: 0; - width: 100%; - } - color: $light-green; - } - - &__animation { - position: relative; - display: inline-block; - cursor: pointer; - &:after { - position: absolute; - /*color: $white;*/ - font-family: 'FontAwesome'; - content: '\f061'; - font-weight: 900; - -webkit-transition: all 0.3s; - -moz-transition: all 0.3s; - transition: all 0.3s; - } - } - - &__animation:hover { - padding: .7em 3em .7em 1em; - } - - &__arrow:after { - left: 130%; - top: .75em; - } - - &__arrow:hover:after { - left: 85%; - } - - &__animation--md:hover { - padding: .65em 3em .65em 1em; - } - - &__arrow--md:hover:after { - left: 75%; - } - - &__arrow--white { - &:after { - position: absolute; - color: $white; - font-family: 'FontAwesome'; - content: '\f061'; - font-weight: 900; - } - } - - /******* Other States ******/ - &__bulkgranules { - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f51e'; - font-weight: 900; - left: 10px; - - } - } - - &__bulkgranules:hover:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f51e'; - font-weight: 900; - visibility: hidden; - } - - &__bulkgranules.button__arrow--md:hover:after { - left: 85%; - } - - &__kibana_open{ - background-color: $light-green; - position: relative; - margin-top: 1em; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f360'; - font-weight: 900; - left: 10px; - } - } - - &__addcollections { - float: right; - margin-top: 1em; - } - - &__deletecollections { - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f187'; - font-weight: 900; - left: 10px; - - } - } - - &__deletecollections:hover:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f187'; - font-weight: 900; - visibility: hidden; - } - - &__goto { - background-color: $light-green; - position: relative; - - &:before{ - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f0a9'; - font-weight: 900; - left: 10px; - } - } - - &__goto:hover:before { - color: $white; - position: absolute; - font-family: 'FontAwesome'; - content: '\f0a9'; - font-weight: 900; - visibility: hidden; - } - - &--tab { - border: none; - background: none; - padding-bottom: .5em; - margin-right: 1em; - color: $grey; - -webkit-transition: all 0.3s; - -moz-transition: all 0.3s; - transition: all 0.3s; - &:hover { - color: $dark-grey; - } - } -} diff --git a/app/src/js/components/Collections/add.js b/app/src/js/components/Collections/add.js index e94c3068a..21cb71d78 100644 --- a/app/src/js/components/Collections/add.js +++ b/app/src/js/components/Collections/add.js @@ -12,7 +12,7 @@ const AddCollection = ({ location = {}, collections, dispatch, schema }) => { const [defaultValue, setDefaultValue] = useState({}); const { state: locationState } = location; const { name, version } = locationState || {}; - const collectionId = getCollectionId({name, version}); + const collectionId = getCollectionId({ name, version }); const { collection: collectionSchema } = schema || {}; const { map: collectionsMap } = collections || {}; const isCopy = !!(name && version); diff --git a/app/src/js/components/Collections/edit.js b/app/src/js/components/Collections/edit.js index 64b299a21..92bd4eda7 100644 --- a/app/src/js/components/Collections/edit.js +++ b/app/src/js/components/Collections/edit.js @@ -13,60 +13,21 @@ import EditRaw from '../EditRaw/edit-raw'; const SCHEMA_KEY = 'collection'; -const ModalBody = ({ isSuccess, isError, isInflight, error, name, version }) => { - return ( -
- {isInflight - ? 'Processing...' - : <> - {`Collection ${name} / ${version} `} - {(isSuccess && !isError) && 'has been updated'} - {isError && - <> - {'has encountered an error.'} -
{error}
- - } - - } -
- ); -}; - -ModalBody.propTypes = { - isError: PropTypes.bool, - isSuccess: PropTypes.bool, - isInflight: PropTypes.bool, - error: PropTypes.string, - name: PropTypes.string, - version: PropTypes.string -}; - const EditCollection = ({ match, collections }) => { const { params: { name, version } } = match; const collectionId = getCollectionId({ name, version }); - const wrapModalBody = ModalBody => ({ ...props }) => { - return ( - - ); - }; - - const ModalBodyWrapper = wrapModalBody(ModalBody); - return ( getCollection(name, version)} - updateRecord={updateCollection} + updateRecord={payload => updateCollection(payload, name, version)} backRoute={`/collections/collection/${name}/${version}`} clearRecordUpdate={clearUpdateCollection} hasModal={true} - type='collection' - ModalBody={ModalBodyWrapper} /> ); }; diff --git a/app/src/js/components/Collections/granules.js b/app/src/js/components/Collections/granules.js index cbda2b83b..b7aa2cf02 100644 --- a/app/src/js/components/Collections/granules.js +++ b/app/src/js/components/Collections/granules.js @@ -21,10 +21,11 @@ import List from '../Table/Table'; import Dropdown from '../DropDown/dropdown'; import Search from '../Search/search'; import statusOptions from '../../utils/status'; -import {strings} from '../locale'; +import { strings } from '../locale'; import { workflowOptionNames } from '../../selectors'; import ListFilters from '../ListActions/ListFilters'; import Breadcrumbs from '../Breadcrumbs/Breadcrumbs'; +import pageSizeOptions from '../../utils/page-size'; const CollectionGranules = ({ dispatch, @@ -42,6 +43,7 @@ const CollectionGranules = ({ const collectionId = getCollectionId(params); const view = getView(); const [workflow, setWorkflow] = useState(); + const query = generateQuery(); const breadcrumbConfig = [ { @@ -138,14 +140,14 @@ const CollectionGranules = ({

{`${displayCase(view)} ${displayName} `} - {`${meta.count && meta.count || 0}`} + {`${(meta.count && meta.count) || 0}`}

{view === 'all' && ( )} +
diff --git a/app/src/js/components/Collections/index.js b/app/src/js/components/Collections/index.js index 2c6475607..f9fa1eb5c 100644 --- a/app/src/js/components/Collections/index.js +++ b/app/src/js/components/Collections/index.js @@ -13,6 +13,8 @@ import CollectionOverview from '../../components/Collections/overview'; import CollectionGranules from '../../components/Collections/granules'; import CollectionIngest from '../../components/Collections/ingest'; import CollectionLogs from '../../components/Collections/logs'; +import DatePickerHeader from '../../components/DatePickerHeader/DatePickerHeader'; +import { listCollections } from '../../actions'; class Collections extends React.Component { constructor () { @@ -20,17 +22,17 @@ class Collections extends React.Component { this.displayName = strings.collection; } + query () { + this.props.dispatch(listCollections()); + } + render () { const { pathname } = this.props.location; const existingCollection = pathname !== '/collections/add'; return (
-
-
-

{strings.collections}

-
-
+
@@ -38,7 +40,7 @@ class Collections extends React.Component {
- + @@ -62,6 +64,7 @@ class Collections extends React.Component { Collections.propTypes = { children: PropTypes.object, + dispatch: PropTypes.func, location: PropTypes.object, queryParams: PropTypes.object }; diff --git a/app/src/js/components/Collections/list.js b/app/src/js/components/Collections/list.js index 4d93e1989..11a33817c 100644 --- a/app/src/js/components/Collections/list.js +++ b/app/src/js/components/Collections/list.js @@ -4,13 +4,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; -import moment from 'moment'; import { applyRecoveryWorkflowToCollection, clearCollectionsSearch, getCumulusInstanceMetadata, listCollections, - searchCollections + searchCollections, + filterCollections, + clearCollectionsFilter } from '../../actions'; import { collectionSearchResult, @@ -28,6 +29,8 @@ import List from '../Table/Table'; import { strings } from '../locale'; import Breadcrumbs from '../Breadcrumbs/Breadcrumbs'; import ListFilters from '../ListActions/ListFilters'; +import Dropdown from '../DropDown/dropdown'; +import pageSizeOptions from '../../utils/page-size'; const breadcrumbConfig = [ { @@ -43,13 +46,6 @@ const breadcrumbConfig = [ class CollectionList extends React.Component { constructor () { super(); - this.displayName = 'CollectionList'; - this.timeOptions = { - '': '', - '1 Week Ago': moment().subtract(1, 'weeks').format(), - '1 Month Ago': moment().subtract(1, 'months').format(), - '1 Year Ago': moment().subtract(1, 'years').format() - }; this.generateQuery = this.generateQuery.bind(this); this.generateBulkActions = this.generateBulkActions.bind(this); } @@ -76,9 +72,12 @@ class CollectionList extends React.Component { } render () { - const { list } = this.props.collections; + const { collections, mmtLinks, datepicker } = this.props; + const { list } = collections; + const { startDateTime, endDateTime } = datepicker || {}; + const hasTimeFilter = startDateTime || endDateTime; + // merge mmtLinks with the collection data; - const mmtLinks = this.props.mmtLinks; const data = list.data.map((collection) => { return { ...collection, @@ -99,7 +98,10 @@ class CollectionList extends React.Component {
-

{strings.all_collections} {count ? ` ${tally(count)}` : 0}

+

+ {hasTimeFilter ? strings.active_collections : strings.all_collections} + {count ? tally(count) : 0} +

+ + @@ -133,10 +147,11 @@ CollectionList.propTypes = { collections: PropTypes.object, mmtLinks: PropTypes.object, dispatch: PropTypes.func, - logs: PropTypes.object, config: PropTypes.object, - location: PropTypes.object + datepicker: PropTypes.object }; +CollectionList.displayName = 'CollectionList'; + export { CollectionList }; export default withRouter(connect(state => state)(CollectionList)); diff --git a/app/src/js/components/Collections/logs.js b/app/src/js/components/Collections/logs.js index 82f6d412f..0e12f7b4a 100644 --- a/app/src/js/components/Collections/logs.js +++ b/app/src/js/components/Collections/logs.js @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { withRouter, Link } from 'react-router-dom'; import { lastUpdated } from '../../utils/format'; import LogViewer from '../Logs/viewer'; -import {strings} from '../locale'; +import { strings } from '../locale'; class CollectionLogs extends React.Component { constructor () { diff --git a/app/src/js/components/Collections/overview.js b/app/src/js/components/Collections/overview.js index 8e8954c31..a52fdb603 100644 --- a/app/src/js/components/Collections/overview.js +++ b/app/src/js/components/Collections/overview.js @@ -25,13 +25,15 @@ import Dropdown from '../DropDown/dropdown'; import SimpleDropdown from '../DropDown/simple-dropdown'; import Search from '../Search/search'; import statusOptions from '../../utils/status'; +import pageSizeOptions from '../../utils/page-size'; import List from '../Table/Table'; import Bulk from '../Granules/bulk'; import Overview from '../Overview/overview'; -import { tableColumns } from '../../utils/table-config/granules'; +import { tableColumns, reingestAction } from '../../utils/table-config/granules'; import { strings } from '../locale'; import DeleteCollection from '../DeleteCollection/DeleteCollection'; import Breadcrumbs from '../Breadcrumbs/Breadcrumbs'; +import ListFilters from '../ListActions/ListFilters'; const breadcrumbConfig = [ { @@ -59,10 +61,10 @@ class CollectionOverview extends React.Component { this.deleteMe, this.errors, this.generateQuery, + this.generateBulkActions, this.gotoGranules, this.load, - this.navigateBack, - this.renderRunBulkGranulesButton + this.navigateBack ].forEach((fn) => (this[fn.name] = fn.bind(this))); } @@ -79,17 +81,6 @@ class CollectionOverview extends React.Component { } } - renderRunBulkGranulesButton () { - return ( - - ); - } - load () { const { name, version } = this.props.match.params; this.props.dispatch(getCumulusInstanceMetadata()); @@ -101,6 +92,21 @@ class CollectionOverview extends React.Component { this.props.history.push(`/collections/collection/${name}/${version}`); } + generateBulkActions () { + const { granules } = this.props; + return [ + reingestAction(granules), + { + Component: + + } + ]; + } + generateQuery () { return { collectionId: getCollectionId(this.props.match.params) @@ -122,7 +128,7 @@ class CollectionOverview extends React.Component { errors () { const { name, version } = this.props.match.params; - const collectionId = getCollectionId({name, version}); + const collectionId = getCollectionId({ name, version }); return [ get(this.props.collections.map, [collectionId, 'error']), get(this.props.collections.deleted, [collectionId, 'error']) @@ -257,47 +263,52 @@ class CollectionOverview extends React.Component { {strings.view_all_granules}
-
-
    -
  • - -
  • -
  • - -
  • -
  • - {this.renderRunBulkGranulesButton()} -
  • -
-
+ > + + + + + +
); diff --git a/app/src/js/components/DatePickerHeader/DatePickerHeader.js b/app/src/js/components/DatePickerHeader/DatePickerHeader.js index 2c801c0fe..637a92a15 100644 --- a/app/src/js/components/DatePickerHeader/DatePickerHeader.js +++ b/app/src/js/components/DatePickerHeader/DatePickerHeader.js @@ -1,13 +1,29 @@ +import Datepicker from '../Datepicker/Datepicker'; +import PropTypes from 'prop-types'; import React from 'react'; -class DatePickerHeader extends React.Component { - render () { - return ( -
-

Future Home For Component

+const DatePickerHeader = ({ heading, onChange }) => { + return ( +
+
+
    +
  • +
    +

    {heading}

    +
    +
  • +
  • + +
  • +
- ); - } -} +
+ ); +}; + +DatePickerHeader.propTypes = { + onChange: PropTypes.func, + heading: PropTypes.string +}; export default DatePickerHeader; diff --git a/app/src/js/components/DatePickerHeader/DatePickerHeader.scss b/app/src/js/components/DatePickerHeader/DatePickerHeader.scss new file mode 100644 index 000000000..19c6c51a6 --- /dev/null +++ b/app/src/js/components/DatePickerHeader/DatePickerHeader.scss @@ -0,0 +1,21 @@ +.datetimeheader{ + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + &__content{ + margin-right: 0px; + } +} + +.content__header .datetime__range { + top: 0px; +} + +.content__header .datetime__internal { + margin-left: auto; +} + +.content__header .selector__hrformat { + margin-right: 0em; +} \ No newline at end of file diff --git a/app/src/js/components/Datepicker/Datepicker.js b/app/src/js/components/Datepicker/Datepicker.js index d1bd5eb10..738c01433 100644 --- a/app/src/js/components/Datepicker/Datepicker.js +++ b/app/src/js/components/Datepicker/Datepicker.js @@ -7,22 +7,19 @@ import DateTimePicker from 'react-datetime-picker'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import withQueryParams from 'react-router-query-params'; -import { DATEPICKER_DATECHANGE, DATEPICKER_DROPDOWN_FILTER, DATEPICKER_HOUR_FORMAT } from '../../actions/types'; -import { urlDateFormat, urlDateProps } from '../../utils/datepicker'; - -const allDateRanges = [ - {value: 'All', label: 'All'}, - {value: 'Custom', label: 'Custom'}, - {value: 1 / 24.0, label: 'Last hour'}, - {value: 1, label: 'Last 24 hours'}, - {value: 7, label: 'Last week'}, - {value: 30, label: 'Last 30 Days'}, - {value: 60, label: 'Last 60 days'}, - {value: 180, label: 'Last 180 days'}, - {value: 366, label: 'Last year'} -]; -const allHourFormats = ['12HR', '24HR']; -const dateTimeFormat = 'YYYY-MM-DDTHH:mm:ss.sss'; +import { + DATEPICKER_DATECHANGE, + DATEPICKER_DROPDOWN_FILTER, + DATEPICKER_HOUR_FORMAT +} from '../../actions/types'; +import { + allDateRanges, + allHourFormats, + dropdownValue, + dateTimeFormat, + urlDateFormat, + urlDateProps +} from '../../utils/datepicker'; /* * If this is a shared URL, grab the date and time and update the datepicker @@ -31,15 +28,21 @@ const dateTimeFormat = 'YYYY-MM-DDTHH:mm:ss.sss'; */ const updateDatepickerStateFromQueryParams = (props) => { const { queryParams } = props; - const values = {...queryParams}; + if (!isEmpty(queryParams)) { + const values = { ...queryParams }; + for (const value in values) { if (urlDateProps.includes(value)) { - values[value] = moment.utc(values[value], urlDateFormat).toDate(); + values[value] = moment.utc(values[value], urlDateFormat).valueOf(); } } - values.dateRange = {value: 'Custom', label: 'Custom'}; - props.dispatch({type: 'DATEPICKER_DATECHANGE', data: {...props.datepicker, ...values}}); + + values.dateRange = dropdownValue(values); + props.dispatch({ + type: 'DATEPICKER_DATECHANGE', + data: { ...props.datepicker, ...values } + }); } }; @@ -55,14 +58,22 @@ class Datepicker extends React.PureComponent { this.handleHourFormatChange = this.handleHourFormatChange.bind(this); this.handleDateTimeRangeChange = this.handleDateTimeRangeChange.bind(this); this.clear = this.clear.bind(this); + this.refresh = this.refresh.bind(this); } componentDidMount () { updateDatepickerStateFromQueryParams(this.props); } + refresh (e) { + const { value, label } = this.props.dateRange; + if (label !== 'Custom') { + this.props.dispatch(this.dispatchDropdownUpdate(value, label)); + } + } + clear () { - const { value, label } = allDateRanges.find(a => a.label === 'All'); + const { value, label } = allDateRanges.find((a) => a.label === 'Custom'); this.props.dispatch(this.dispatchDropdownUpdate(value, label)); } @@ -93,24 +104,31 @@ class Datepicker extends React.PureComponent { handleDateTimeRangeChange (name, newValue) { // User input is in UTC, but the DateTimePicker component interprets it's // data as local time. So we need convert the Date value to UTC. - const utcValue = moment.utc(moment(newValue).format(dateTimeFormat)).toDate(); - if (isNaN(utcValue.valueOf())) return; + let utcValue = null; + if (newValue !== null) { + utcValue = moment.utc(moment(newValue).format(dateTimeFormat)).valueOf(); + if (isNaN(utcValue)) return; + } const updatedProps = { startDateTime: this.props.startDateTime, endDateTime: this.props.endDateTime, [name]: utcValue }; - updatedProps.dateRange = allDateRanges.find(a => a.label === 'Custom'); - this.props.dispatch({type: DATEPICKER_DATECHANGE, data: updatedProps}); + updatedProps.dateRange = allDateRanges.find((a) => a.label === 'Custom'); + this.props.dispatch({ type: DATEPICKER_DATECHANGE, data: updatedProps }); this.updateQueryParams(updatedProps); this.onChange(); } updateQueryParams (newProps) { - const updatedQueryParams = {...this.props.queryParams}; + const updatedQueryParams = { ...this.props.queryParams }; urlDateProps.map((time) => { let urlValue; - if (newProps[time] !== null) { + // If user selects 'Recent', drop the start and end date/time query + // parameters, otherwise on the next navigation, the dropdown will switch + // back to 'Custom'. Excluding these query params ensures that 'Recent' + // remains selected until the user selects otherwise. + if (newProps.dateRange.value !== 'Recent' && newProps[time] !== null) { urlValue = moment.utc(newProps[time]).format(urlDateFormat); } updatedQueryParams[time] = urlValue; @@ -121,13 +139,18 @@ class Datepicker extends React.PureComponent { renderDateRangeDropDown () { return ( -
+
@@ -139,23 +162,20 @@ class Datepicker extends React.PureComponent { return ( - {allHourFormats.map((option, i) => { - return ( - - ); - }) - } +
+ +
); } @@ -163,10 +183,12 @@ class Datepicker extends React.PureComponent { renderDateTimeRange (name) { const hourFormat = this.props.hourFormat; const value = this.props[name]; - const locale = (hourFormat === '24HR') ? 'en-GB' : 'en-US'; - const format = (hourFormat === '24HR') ? 'MM/dd/yyyyy HH:mm' : 'MM/dd/yyyyy hh:mm a'; + const locale = hourFormat === '24HR' ? 'en-GB' : 'en-US'; + const format = `MM/dd/yyyyy ${hourFormat === '24HR' ? 'HH:mm' : 'hh:mm a'}`; - const utcValue = isNil(value) ? null : moment(moment.utc(value).format(dateTimeFormat)).toDate(); + const utcValue = isNil(value) + ? null + : moment(moment.utc(value).format(dateTimeFormat)).toDate(); return ( -
-
-
    +
    +
    +
    +
    • - { this.renderDateRangeDropDown() } + + {this.renderDateRangeDropDown()}
    • - { this.renderDateTimeRange('startDateTime') } -
    • -
    • - to + + {this.renderDateTimeRange('startDateTime')}
    • - { this.renderDateTimeRange('endDateTime') } + + {this.renderDateTimeRange('endDateTime')}
    • -
    • - { this.renderHourFormatSelect() } +
    • + + {this.renderHourFormatSelect()}
    • + {this.props.hideWrapper || ( +
    • + +
    • + )}
    -
    -

    Date and Time Range

    -
    -
    -
    - + {this.props.hideWrapper || ( +
    +
      +
    • +

      Date and Time Range

      +
    • +
    • +
      + +
      +
    • +
    +
    +
    + )}
    ); @@ -230,13 +274,16 @@ Datepicker.propTypes = { value: PropTypes.node, label: PropTypes.string }), - startDateTime: PropTypes.instanceOf(Date), - endDateTime: PropTypes.instanceOf(Date), - hourFormat: PropTypes.oneOf(allHourFormats), + startDateTime: PropTypes.number, + endDateTime: PropTypes.number, + hourFormat: PropTypes.oneOf(allHourFormats.map((a) => a.label)), queryParams: PropTypes.object, setQueryParams: PropTypes.func, onChange: PropTypes.func, dispatch: PropTypes.func, + hideWrapper: PropTypes.bool }; -export default withRouter(withQueryParams()(connect((state) => state.datepicker)(Datepicker))); +export default withRouter( + withQueryParams()(connect(state => state.datepicker)(Datepicker)) +); diff --git a/app/src/js/components/Datepicker/Datepicker.scss b/app/src/js/components/Datepicker/Datepicker.scss index 899115b40..ee21c5b17 100644 --- a/app/src/js/components/Datepicker/Datepicker.scss +++ b/app/src/js/components/Datepicker/Datepicker.scss @@ -2,6 +2,16 @@ &__info{ font-weight: $base-font-regular; } + &__header{ + display: flex; + justify-content: space-between; + } + &__header li:first-child{ + float: left; + } + &__header li:nth-child(2){ + float: right; + } &__wrapper{ background-color: $white; padding: 1.5em 1em .5em; @@ -10,8 +20,8 @@ border: 1px solid #eceaea; box-shadow: $shadow__default; font-size: .86em; - width: 775px; - height: 80px; + width: 1015px; + height: 127px; margin: .8em 1.5em .8em 0; color: #8C8C8C; -webkit-transition: all 0.3s; @@ -21,10 +31,22 @@ clear: right; position: absolute; z-index: 0; - top: 365px; + top: 268px; + } + &__wrapper h3{ + font-size: 1.3em; + font-weight: $base-font-semibold; + margin-bottom: .5em; + } + &__wrapper hr{ + height: 1px; + background-color: $lightest-grey; + border: none; + margin-bottom: 1em; + margin-top: .25em; } &__internal{ - display: inline-flex; + display: flex; align-items: center; justify-items: center; flex-direction: row; @@ -34,83 +56,52 @@ margin-right: 2em; } - &__internal input[type="radio"] { - font-size: 2em; - margin-right: .25em; - } - - &__internal .selector__hrformat{ - display: block; - position: relative; - padding-left: 24px; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - } - - &__internal .selector__hrformat input{ - position: absolute; - opacity: 0; - cursor: pointer; - height: 0; - width: 0; - } - - &__internal .selector__hrformat input:checked ~ .selector{ - background-color: $white; - border: 2px solid $ocean-blue; - &:after{ - display: block; - } + &__internal label{ + font-size: 1em; + font-weight: $base-font-regular; + margin-bottom: .4em; } - &__internal .selector{ - position: absolute; - top: 0; - left: 0; - height: 15px; - width: 15px; - background-color: $white; - border-radius: 50%; - border: 2px solid $lightest-grey; - &:hover{ - background-color: rgba($color: #2685c1, $alpha: 0.54) - } - &:after{ - content: ""; - position: absolute; - display: none; - } + &.selector__hrformat{ + position: relative; + &:after { + position: absolute; + font-family: 'FontAwesome'; + content: '\f0dc'; + color: $white; + font-weight: 900; + top: .28em; + left: 4em; + } } - &__internal .selector__hrformat .selector:after{ - top: .24em; - left: .2rem; - width: .5rem; - height: .5rem; - border-radius: 50%; + &.selector__hrformat select{ + display: block; + position: relative; + padding-left: 11px; background-color: $ocean-blue; - border: 1px solid $ocean-blue; + color: $white; + width: 75px; + height: 28px; } &.dropdown__dtrange{ + position: relative; &:after { position: absolute; font-family: 'FontAwesome'; content: '\f0dc'; color: $white; font-weight: 900; - top: 1em; - left: 7em; + top: .55em; + left: 8.3em; } } &.dropdown__dtrange select{ background-color: $ocean-blue; color: $white; - width: 120px; + width: 135px; height: 35px; font-size: 14px; position: relative; @@ -119,14 +110,16 @@ &__range{ z-index: 1; position: relative; - top: 51px; - left: 13px; + top: 73px; + display: flex; + justify-content: center; + width: 1040px; } &__clear{ - margin-left: 850px; position: relative; - z-index: 2; + margin: .7em 0em 0em 5.1em !important; + float: right; &:after { position: absolute; font-family: 'FontAwesome'; @@ -137,4 +130,17 @@ left: 1em; } } -} \ No newline at end of file + + &__refresh{ + position: relative; + &:after { + position: absolute; + font-family: 'FontAwesome'; + content: '\f01e'; + color: $white; + font-weight: 900; + top: .25em; + left: .7em; + } + } +} diff --git a/app/src/js/components/DeleteCollection/BatchDeleteCollectionModal.js b/app/src/js/components/DeleteCollection/BatchDeleteCollectionModal.js deleted file mode 100644 index 831b9f40a..000000000 --- a/app/src/js/components/DeleteCollection/BatchDeleteCollectionModal.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import Modal from 'react-bootstrap/Modal'; -// import Button from '../Button/Button'; - -class BatchDeleteCollectionModal extends React.Component { - render () { - return ( - - - Delete Batch Collections - -

    - You have submitted a request to delete the following collections - { /* Need to map

  • based on what collections where selected by user in the table */ } -
      -
    • - {/* {(`${collectionName} ${collectionVersion}`)} */} -
    • -
    . - Are you sure that you want to delete all of these? -

    - - - - - - - ); - } -} - -BatchDeleteCollectionModal.propTypes = { -}; - -export default BatchDeleteCollectionModal; diff --git a/app/src/js/components/DeleteCollection/BatchDeleteCompleteContent.js b/app/src/js/components/DeleteCollection/BatchDeleteCompleteContent.js new file mode 100644 index 000000000..615101503 --- /dev/null +++ b/app/src/js/components/DeleteCollection/BatchDeleteCompleteContent.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { collectionNameVersion } from '../../utils/format'; + +const BatchDeleteCompleteContent = ({ + results, + error +}) => { + return ( + <> + {(results && results.length > 0) && + <> +

    Successfully deleted these collections:

    +
      + {results.map((result, index) => { + const { name, version } = collectionNameVersion(result); + return
    • {name} / {version}
    • ; + })} +
    + + } + {error && {error}} + + ); +}; + +BatchDeleteCompleteContent.propTypes = { + results: PropTypes.array, + error: PropTypes.string +}; + +export default BatchDeleteCompleteContent; diff --git a/app/src/js/components/DeleteCollection/BatchDeleteConfirmContent.js b/app/src/js/components/DeleteCollection/BatchDeleteConfirmContent.js new file mode 100644 index 000000000..f4a82abb8 --- /dev/null +++ b/app/src/js/components/DeleteCollection/BatchDeleteConfirmContent.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { collectionNameVersion } from '../../utils/format'; + +const BatchDeleteConfirmContent = ({ selected = [] }) => { + return ( + <> +

    You have submitted a request to delete the following collections

    +
      + {selected.map((selection, index) => { + const { name, version } = collectionNameVersion(selection); + return
    • {name} / {version}
    • ; + })} +
    +

    Are you sure that you want to delete all of these?

    + + ); +}; + +BatchDeleteConfirmContent.propTypes = { + selected: PropTypes.array +}; + +export default BatchDeleteConfirmContent; diff --git a/app/src/js/components/DeleteCollection/BatchDeleteWithGranulesContent.js b/app/src/js/components/DeleteCollection/BatchDeleteWithGranulesContent.js new file mode 100644 index 000000000..193763736 --- /dev/null +++ b/app/src/js/components/DeleteCollection/BatchDeleteWithGranulesContent.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { collectionNameVersion } from '../../utils/format'; + +const BatchDeleteWithGranulesContent = ({ selectionsWithGranules }) => { + return ( + <> + + You have submitted a request to delete multiple collections. + The following collections contain associated granules: + +
      + {selectionsWithGranules.map((collection, index) => { + const { name, version } = collectionNameVersion(collection); + return ( +
    • {`${name} / ${version}`}
    • + ); + })} +
    + + In order to complete this request, the granules associated with the above collections must first be deleted. + Would you like to be redirected to the Granules pages? + + + ); +}; + +BatchDeleteWithGranulesContent.propTypes = { + selectionsWithGranules: PropTypes.array +}; + +export default BatchDeleteWithGranulesContent; diff --git a/app/src/js/components/DropDown/dropdown-async-command.js b/app/src/js/components/DropDown/dropdown-async-command.js index 694af3e35..ebe267db9 100644 --- a/app/src/js/components/DropDown/dropdown-async-command.js +++ b/app/src/js/components/DropDown/dropdown-async-command.js @@ -24,8 +24,7 @@ class DropdownAsync extends React.Component { } onOutsideClick (e) { - if (findDOMNode(this).contains(e.target)) return; - else this.setState({ showActions: false }); + if (!findDOMNode(this).contains(e.target)) this.setState({ showActions: false }); } toggleActions (e) { @@ -61,6 +60,8 @@ class DropdownAsync extends React.Component { confirmAction={d.confirmAction} confirmText={d.confirmText} confirmOptions={d.confirmOptions} + showSuccessModal={d.postActionModal} + postActionText={d.postActionText} className={'link--no-underline'} element='a' text={d.text} /> diff --git a/app/src/js/components/DropDown/dropdown.js b/app/src/js/components/DropDown/dropdown.js index 152250586..6b377a81a 100644 --- a/app/src/js/components/DropDown/dropdown.js +++ b/app/src/js/components/DropDown/dropdown.js @@ -32,7 +32,7 @@ function renderMenu (items, value, style) { * @returns {string} - either the correct label from the status Object, or the input status. */ const statusToLabel = (statusObject = {}, status) => { - let result = Object.keys(statusObject).filter( + const result = Object.keys(statusObject).filter( (label) => statusObject[label] === status ); if (result.length) return result[0]; @@ -51,19 +51,19 @@ class Dropdown extends React.Component { } componentDidUpdate (prevProps, prevState, snapshot) { - const {paramKey, dispatch, action, options, queryParams} = this.props; + const { paramKey, dispatch, action, options, queryParams } = this.props; if (queryParams[paramKey] !== prevProps.queryParams[paramKey]) { let value = queryParams[paramKey]; dispatch(action({ key: paramKey, value })); if (value) value = statusToLabel(options, value); - this.setState({value}); // eslint-disable-line react/no-did-update-set-state + this.setState({ value }); // eslint-disable-line react/no-did-update-set-state } } componentDidMount () { const { dispatch, getOptions, action, paramKey, queryParams } = this.props; if (getOptions) { dispatch(getOptions()); } - if (queryParams[paramKey]) dispatch(action({key: paramKey, value: queryParams[paramKey]})); + if (queryParams[paramKey]) dispatch(action({ key: paramKey, value: queryParams[paramKey] })); } componentWillUnmount () { @@ -97,7 +97,7 @@ class Dropdown extends React.Component { // `options` are expected in the following format: // {displayValue1: optionElementValue1, displayValue2, optionElementValue2, ...} const { options, label, paramKey, inputProps } = this.props; - const items = options ? Object.keys(options).map(label => ({label, value: options[label]})) : []; + const items = options ? Object.keys(options).map(label => ({ label, value: options[label] })) : []; // Make sure this form ID is unique! // If needed in future, could add MD5 hash of stringified options, diff --git a/app/src/js/components/DropDown/simple-dropdown.js b/app/src/js/components/DropDown/simple-dropdown.js index e1e58ede6..a523bdbfd 100644 --- a/app/src/js/components/DropDown/simple-dropdown.js +++ b/app/src/js/components/DropDown/simple-dropdown.js @@ -26,7 +26,7 @@ class Dropdown extends React.Component { const renderedOptions = options[0] === '' || noNull ? options : [''].concat(options); return ( -
    +
    • @@ -42,7 +42,7 @@ class Dropdown extends React.Component {
- {error} + {error && {error}}
); } diff --git a/app/src/js/components/Edit/edit.js b/app/src/js/components/Edit/edit.js index e2fc91906..19514c594 100644 --- a/app/src/js/components/Edit/edit.js +++ b/app/src/js/components/Edit/edit.js @@ -9,7 +9,7 @@ import Loading from '../LoadingIndicator/loading-indicator'; import Schema from '../FormSchema/schema'; import merge from '../../utils/merge'; import _config from '../../config'; -import {strings} from '../locale'; +import { strings } from '../locale'; const { updateDelay } = _config; @@ -39,8 +39,8 @@ class EditRecord extends React.Component { } componentDidUpdate (prevProps) { - const { pk } = this.props; - const { dispatch, history, clearRecordUpdate, backRoute, state } = prevProps; + const { pk, state } = this.props; + const { dispatch, history, clearRecordUpdate, backRoute } = prevProps; const updateStatus = get(state.updated, [pk, 'status']); if (updateStatus === 'success') { return setTimeout(() => { diff --git a/app/src/js/components/EditRaw/edit-raw.js b/app/src/js/components/EditRaw/edit-raw.js index 7e1999863..ab1f1434f 100644 --- a/app/src/js/components/EditRaw/edit-raw.js +++ b/app/src/js/components/EditRaw/edit-raw.js @@ -31,9 +31,7 @@ const EditRaw = ({ pk, schema, schemaKey, - hasModal, - type, - ModalBody + hasModal }) => { const [record, setRecord] = useState(defaultState); const [showModal, setShowModal] = useState(false); @@ -46,6 +44,7 @@ const EditRaw = ({ const isError = !!error; const buttonText = isInflight ? 'loading...' : isSuccess ? 'Success!' : 'Submit'; + const recordDisplayName = displayCase(schemaKey); // get record and schema // ported from componentDidMount @@ -84,7 +83,7 @@ const EditRaw = ({ error: newRecord.error }); } else if (newRecord.data) { - let data = removeReadOnly(newRecord.data, recordSchema); + const data = removeReadOnly(newRecord.data, recordSchema); try { var text = JSON.stringify(data, null, '\t'); } catch (error) { @@ -176,17 +175,31 @@ const EditRaw = ({ {hasModal && - +
+ {isInflight + ? 'Processing...' + : <> + {`${recordDisplayName} ${recordPk} `} + {(isSuccess && !isError) && 'has been updated'} + {isError && + <> + {'has encountered an error.'} +
{error}
+ + } + + } +
}
); @@ -203,12 +216,7 @@ EditRaw.propTypes = { getRecord: PropTypes.func, updateRecord: PropTypes.func, clearRecordUpdate: PropTypes.func, - hasModal: PropTypes.bool, - type: PropTypes.string, - ModalBody: PropTypes.oneOfType([ - PropTypes.node, - PropTypes.func - ]) + hasModal: PropTypes.bool }; export default withRouter(connect(state => ({ diff --git a/app/src/js/components/Errors/ErrorBoundary.js b/app/src/js/components/Errors/ErrorBoundary.js index 980240fe0..7de5bc859 100644 --- a/app/src/js/components/Errors/ErrorBoundary.js +++ b/app/src/js/components/Errors/ErrorBoundary.js @@ -25,7 +25,7 @@ class ErrorBoundary extends Component { errorInfo: errorInfo }); // You can also log the error to an error reporting service - ; + return (); } render () { diff --git a/app/src/js/components/Errors/report.js b/app/src/js/components/Errors/report.js index 9c9810d01..8bbd73d08 100644 --- a/app/src/js/components/Errors/report.js +++ b/app/src/js/components/Errors/report.js @@ -7,7 +7,6 @@ import { truncate } from '../../utils/format'; class ErrorReport extends React.Component { constructor () { super(); - this.displayName = 'ErrorReport'; this.scrollToTop = this.scrollToTop.bind(this); this.truncate = this.truncate.bind(this); this.renderSingleError = this.renderSingleError.bind(this); @@ -16,7 +15,7 @@ class ErrorReport extends React.Component { } componentDidUpdate (prevProps) { - if (this.props.report !== prevProps.report) { + if (!this.props.disableScroll && (this.props.report !== prevProps.report)) { this.scrollToTop(); } } @@ -37,7 +36,7 @@ class ErrorReport extends React.Component { trigger = this.truncate(report); } // No need to make error collapsible if the truncated - // output is the same length as the original ouptut + // output is the same length as the original output if (typeof report === 'string' && report === trigger && report.length === trigger.length) { @@ -59,7 +58,7 @@ class ErrorReport extends React.Component {
); } else if (report instanceof Error) { - let name = report.name || 'Error'; + const name = report.name || 'Error'; let message, stack; if (!report.message) { message = JSON.stringify(report); @@ -98,7 +97,7 @@ class ErrorReport extends React.Component {
); } else { - let stringified = this.truncate(JSON.stringify(obj)); + const stringified = this.truncate(JSON.stringify(obj)); return

{stringified}

; } } @@ -116,7 +115,8 @@ class ErrorReport extends React.Component { ErrorReport.propTypes = { report: PropTypes.any, - truncate: PropTypes.bool + truncate: PropTypes.bool, + disableScroll: PropTypes.bool }; export default ErrorReport; diff --git a/app/src/js/components/Executions/execution-events.js b/app/src/js/components/Executions/execution-events.js new file mode 100644 index 000000000..42fdca712 --- /dev/null +++ b/app/src/js/components/Executions/execution-events.js @@ -0,0 +1,159 @@ +'use strict'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import cloneDeep from 'lodash.clonedeep'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { withRouter } from 'react-router-dom'; +import { + getExecutionStatus, + getCumulusInstanceMetadata, + searchExecutionEvents, + clearExecutionEventsSearch +} from '../../actions'; + +import { tableColumns } from '../../utils/table-config/execution-status'; + +import ErrorReport from '../Errors/report'; +import Search from '../Search/search'; + +import { getEventDetails } from './execution-graph-utils'; +import SortableTable from '../SortableTable/SortableTable'; + +class ExecutionEvents extends React.Component { + constructor () { + super(); + this.navigateBack = this.navigateBack.bind(this); + this.errors = this.errors.bind(this); + this.renderEvents = this.renderEvents.bind(this); + } + + componentDidMount () { + const { dispatch } = this.props; + const { executionArn } = this.props.match.params; + dispatch(getExecutionStatus(executionArn)); + dispatch(getCumulusInstanceMetadata()); + } + + componentDidUpdate (prevProps) { + const { dispatch } = this.props; + const { executionArn } = this.props.match.params; + const { search } = this.props.location; + const { search: prevSearch } = prevProps.location; + if (search !== prevSearch) { + dispatch(getExecutionStatus(executionArn)); + } + } + + renderEvents () { + const { executionStatus } = this.props; + const { executionHistory: { events } } = executionStatus; + const mutableEvents = cloneDeep(events); + mutableEvents.forEach((event) => { + event.eventDetails = getEventDetails(event); + }); + + return ( + a.id > b.id ? 1 : -1)} + dispatch={this.props.dispatch} + tableColumns={tableColumns} + rowId='id' + sortIdx='id' + props={[]} + order='asc' + /> + ); + } + + navigateBack () { + const { history } = this.props; + history.push('/executions'); + } + + errors () { + return [].filter(Boolean); + } + + render () { + const { executionStatus, dispatch } = this.props; + if (!executionStatus.execution) return null; + + const errors = this.errors(); + return ( +
+
+

+ Events for Execution {executionStatus.execution.name} +

+ + {errors.length ? : null} +
+ + { + (executionStatus.stateMachine && executionStatus.executionHistory) + ?
+
+

Details About The Events

+
+
+ : null + } + + {(executionStatus.executionHistory) + ?
+
+

To find all task name and versions, select “More Details” for the last Lambda- or Activity-type event. There you should find a key / value pair “workflow_tasks” which lists all tasks’ version, name and arn.

+

+

NOTE: Task / version tracking is enabled as of Cumulus version 1.9.1.

+

+

NOTE: If the task output is greater than 10KB, the full message will be stored in an S3 Bucket. In these scenarios, task and version numbers are not part of the Lambda or Activity event output.

+

+ Related workflow will open up into another window to view. +
+
+

All Events + + {`${executionStatus.executionHistory.events.length || 0}`} + +

+ View Workflows +
+
+ +
+ + {this.renderEvents()} +
+ : null} + +
+ ); + } +} + +ExecutionEvents.propTypes = { + executionStatus: PropTypes.object, + match: PropTypes.object, + dispatch: PropTypes.func, + location: PropTypes.object, + history: PropTypes.object +}; + +ExecutionEvents.displayName = 'Execution Events'; + +export { ExecutionEvents }; + +export default withRouter(connect(state => ({ + executionStatus: state.executionStatus, + cumulusInstance: state.cumulusInstance +}))(ExecutionEvents)); diff --git a/app/src/js/components/Executions/execution-graph-utils.js b/app/src/js/components/Executions/execution-graph-utils.js index 04e45be3e..87f7f0f9e 100644 --- a/app/src/js/components/Executions/execution-graph-utils.js +++ b/app/src/js/components/Executions/execution-graph-utils.js @@ -37,7 +37,7 @@ export const draw = (graph) => { var i; for (i = 0; i < nodes.length; i++) { var node = nodes[i]; - setNode(g, node.id, {label: node.id, class: [node.type, node.status].join(' ')}); + setNode(g, node.id, { label: node.id, class: [node.type, node.status].join(' ') }); if (node.parent) { setParent(g, node.id, node.parent.id); } diff --git a/app/src/js/components/Executions/execution-logs.js b/app/src/js/components/Executions/execution-logs.js index f7f2bfe78..c8f3a32ef 100644 --- a/app/src/js/components/Executions/execution-logs.js +++ b/app/src/js/components/Executions/execution-logs.js @@ -8,17 +8,21 @@ import { withRouter } from 'react-router-dom'; import ErrorReport from '../Errors/report'; class ExecutionLogs extends React.Component { - constructor () { - super(); - this.displayName = 'Execution'; + constructor (props) { + super(props); this.navigateBack = this.navigateBack.bind(this); this.errors = this.errors.bind(this); + this.executionName = this.getExecutionName(); } componentDidMount () { const { dispatch } = this.props; - const { executionName } = this.props.match.params; - dispatch(getExecutionLogs(executionName)); + dispatch(getExecutionLogs(this.executionName)); + } + + getExecutionName () { + const { executionArn } = this.props.match.params; + return executionArn.split(':').pop(); } navigateBack () { @@ -32,16 +36,14 @@ class ExecutionLogs extends React.Component { render () { const { executionLogs } = this.props; - const { executionName } = this.props.match.params; if (!executionLogs.results) return null; - const errors = this.errors(); return (

- Logs for Execution {executionName} + Logs for Execution {this.executionName}

{errors.length ? : null} @@ -72,6 +74,8 @@ ExecutionLogs.propTypes = { history: PropTypes.object }; +ExecutionLogs.displayName = 'Execution Logs'; + export default withRouter(connect(state => ({ executionLogs: state.executionLogs }))(ExecutionLogs)); diff --git a/app/src/js/components/Executions/execution-status-graph.js b/app/src/js/components/Executions/execution-status-graph.js index 6c3f74f3e..13d2c2fba 100644 --- a/app/src/js/components/Executions/execution-status-graph.js +++ b/app/src/js/components/Executions/execution-status-graph.js @@ -12,11 +12,23 @@ import { addEventsToGraph, draw } from './execution-graph-utils'; +import Modal from 'react-bootstrap/Modal'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; // dagre-d3 expects d3 to be attached to the window if (process.env.NODE_ENV !== 'test') window.d3 = d3; class ExecutionStatusGraph extends React.Component { + constructor () { + super(); + this.handleClick = this.handleClick.bind(this); + this.onShow = this.onShow.bind(this); + this.onHide = this.onHide.bind(this); + this.state = { + showModal: false + }; + } + componentDidMount () { const { executionStatus: { @@ -26,26 +38,70 @@ class ExecutionStatusGraph extends React.Component { } = this.props; const workflow = JSON.parse(stateMachine.definition); - - var events = getExecutionEvents(executionHistory); - var graph = workflowToGraph(workflow); + const events = getExecutionEvents(executionHistory); + const graph = workflowToGraph(workflow); addEventsToGraph(events, graph); this.g = draw(graph); - var render = new dagre.render(); - var svg = d3.select('svg'); - render(svg, this.g); - var height = d3.select('svg g').node().getBBox().height; - svg.style('height', height + 10); - svg.style('padding-right', 150); + this.renderGraph('.visual', this.g); } componentWillUnmount () { this.g = null; } + renderGraph (svgSelector) { + const render = new dagre.render(); + const svg = d3.select(svgSelector); + render(svg, this.g); + const height = d3.select(`${svgSelector} g`).node().getBBox().height; + const width = d3.select(`${svgSelector} g`).node().getBBox().width; + svg.attr('viewBox', `0 0 ${width} ${height}`); + svg.attr('width', '100%'); + svg.attr('height', '100%'); + } + + handleClick (e) { + e.preventDefault(); + this.setState({ showModal: true }); + } + + onHide () { + this.setState({ showModal: false }); + } + + onShow () { + this.renderGraph('.modal-svg'); + } + render () { + const { showModal } = this.state; return ( - +
+
+
Click to enlarge to fullscreen
+
+
+
+
+ +
+
+ + +
+
Click to return to execution view
+
+
+
+ + + +
+
); } } diff --git a/app/src/js/components/Executions/execution-status.js b/app/src/js/components/Executions/execution-status.js index dffd77ac8..1c2b1804e 100644 --- a/app/src/js/components/Executions/execution-status.js +++ b/app/src/js/components/Executions/execution-status.js @@ -1,6 +1,5 @@ 'use strict'; import React from 'react'; -import Collapse from 'react-collapsible'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import get from 'lodash.get'; @@ -8,22 +7,25 @@ import { getExecutionStatus, getCumulusInstanceMetadata } from '../../actions'; import { displayCase, fullDate, parseJson } from '../../utils/format'; import { withRouter, Link } from 'react-router-dom'; import { kibanaExecutionLink } from '../../utils/kibana'; - -import { tableColumns } from '../../utils/table-config/execution-status'; +import { window } from '../../utils/browser'; import ErrorReport from '../Errors/report'; import ExecutionStatusGraph from './execution-status-graph'; -import { getEventDetails } from './execution-graph-utils'; -import SortableTable from '../SortableTable/SortableTable'; +import Metadata from '../Table/Metadata'; +import DefaultModal from '../Modal/modal'; class ExecutionStatus extends React.Component { constructor () { super(); - this.displayName = 'Execution'; this.navigateBack = this.navigateBack.bind(this); this.errors = this.errors.bind(this); - this.renderEvents = this.renderEvents.bind(this); + this.openModal = this.openModal.bind(this); + this.closeModal = this.closeModal.bind(this); + this.state = { + showInputModal: false, + showOutputModal: false + }; } componentDidMount () { @@ -42,154 +44,200 @@ class ExecutionStatus extends React.Component { return [].filter(Boolean); } - renderEvents () { - const { executionStatus } = this.props; - let { executionHistory: { events } } = executionStatus; - events.forEach((event) => { - event.eventDetails = getEventDetails(event); - }); + openModal (type) { + switch (type) { + case 'input': + this.setState({ showInputModal: true }); + break; + case 'output': + this.setState({ showOutputModal: true }); + } + } - return ( - a.id > b.id ? 1 : -1)} - dispatch={this.props.dispatch} - tableColumns={tableColumns} - rowId='id' - sortIdx='id' - props={[]} - order='asc' - /> - ); + closeModal (type) { + switch (type) { + case 'input': + this.setState({ showInputModal: false }); + break; + case 'output': + this.setState({ showOutputModal: false }); + } } render () { + const { showInputModal, showOutputModal } = this.state; const { executionStatus, cumulusInstance } = this.props; - if (!executionStatus.execution) return null; - - const input = (executionStatus.execution.input) - ?
- -
{parseJson(executionStatus.execution.input)}
-
-
- :
N/A
; - - let output, outputJson, asyncOperationId; - if (executionStatus.execution.output) { - outputJson = JSON.parse(executionStatus.execution.output, null, 2); - output = (executionStatus.execution.output) - ?
- -
{parseJson(executionStatus.execution.output)}
-
-
- :
N/A
; - asyncOperationId = get(outputJson.cumulus_meta, 'asyncOperationId'); - } - let parentARN; - if (executionStatus.execution.input) { - const input = JSON.parse(executionStatus.execution.input); - const parent = get(input.cumulus_meta, 'parentExecutionArn'); - if (parent) { - parentARN =
{parent}
; - } else { - parentARN =
N/A
; - } - } else { - parentARN =
N/A
; - } + const { execution, executionHistory, stateMachine } = executionStatus; - const errors = this.errors(); + if (!execution) return null; - const kibanaLink = kibanaExecutionLink(cumulusInstance, executionStatus.execution.name); + const { name } = execution; - let logsLink; - if (kibanaLink && kibanaLink.length) { - logsLink =
View Logs in Kibana
; - } else { - logsLink =
View Execution Logs
; - } + const errors = this.errors(); + + const metaAccessors = [ + { + label: 'Execution Status', + property: 'status', + accessor: d => { + return ( + {displayCase(d)} + ); + } + }, + { + label: 'Execution Arn', + property: 'executionArn' + }, + { + label: 'State Machine Arn', + property: 'stateMachineArn' + }, + { + label: 'Async Operation ID', + property: 'output', + accessor: d => { + if (!d) return; + const outputJson = JSON.parse(d); + return get(outputJson.cumulus_meta, 'asyncOperationId'); + } + }, + { + label: 'Started', + property: 'startDate', + accessor: fullDate + }, + { + label: 'Ended', + property: 'stopDate', + accessor: fullDate + }, + { + label: 'Parent Workflow Execution', + property: 'input', + accessor: d => { + if (!d) return 'N/A'; + const input = JSON.parse(d); + const parent = get(input.cumulus_meta, 'parentExecutionArn'); + if (parent) { + return {parent}; + } else { + return 'N/A'; + } + } + }, + { + label: 'Input', + property: 'input', + accessor: d => { + if (d) { + return ( + <> + + this.closeModal('input')} + hasConfirmButton={false} + cancelButtonClass='button--close' + cancelButtonText='Close' + className='execution__modal' + > +
{parseJson(d)}
+
+ + ); + } else { + return 'N/A'; + } + } + }, + { + label: 'Output', + property: 'output', + accessor: d => { + if (d) { + const jsonData = new Blob([d], { type: 'text/json' }); + const downloadUrl = window ? window.URL.createObjectURL(jsonData) : ''; + return ( + <> + + + Execution Output + Download File + + } + onCloseModal={() => this.closeModal('output')} + hasConfirmButton={false} + cancelButtonClass='button--close' + cancelButtonText='Close' + className='execution__modal' + > +
{parseJson(d)}
+
+ + ); + } else { + return 'N/A'; + } + } + }, + { + label: 'Logs', + property: 'executionArn', + accessor: d => { + const kibanaLink = kibanaExecutionLink(cumulusInstance, d); + const className = 'button button--small button__goto button__arrow button__animation button__arrow--white'; + if (kibanaLink && kibanaLink.length) { + return View Logs in Kibana; + } else { + return View Execution Logs; + } + } + } + ]; return (

- Execution {executionStatus.arn} + Execution {name}

- {errors.length ? : null} + {(errors.length > 0) && }
{/* stateMachine's definition and executionHistory's event statuses are needed to draw the graph */} - { - (executionStatus.stateMachine && executionStatus.executionHistory) - ?
-
-

Visual workflow

-
- - -
- : null + {(stateMachine && executionHistory) && +
+
+

Visual

+
+ +
} -
+
-

Execution Details

+

Details

+
+
+
- -
-
Execution Status:
-
{displayCase(executionStatus.execution.status)}

- -
Execution Arn:
-
{executionStatus.execution.executionArn}

- -
State Machine Arn:
-
{executionStatus.execution.stateMachineArn}

- - { asyncOperationId ? (
-
Async Operation ID
-
{asyncOperationId}
-
) : null } - -
Started:
-
{fullDate(executionStatus.execution.startDate)}

- -
Ended:
-
{fullDate(executionStatus.execution.stopDate)}

- -
Parent Workflow Execution
- {parentARN} -
- -
Input:
- {input} -
- -
Output:
- {output} -
- -
Logs:
- {logsLink} -
-
- - {(executionStatus.executionHistory) - ?
-
-

Events

-

To find all task name and versions, select More details for the last Lambda- or Activity-type event. There you should find a key / value pair "workflow_tasks" which lists all tasks' version, name and arn.

-

NOTE:Task / version tracking is enabled as of Cumulus version 1.9.1.

-

NOTE:If the task output is greater than 10KB, the full message will be stored in an S3 Bucket. In these scenarios, task and version numbers are not part of the Lambda or Activity event output.

-
- - {this.renderEvents()} -
- : null} -
); } @@ -203,6 +251,8 @@ ExecutionStatus.propTypes = { history: PropTypes.object }; +ExecutionStatus.displayName = 'Execution'; + export { ExecutionStatus }; export default withRouter(connect(state => ({ diff --git a/app/src/js/components/Executions/index.js b/app/src/js/components/Executions/index.js index d6906c235..c99de2488 100644 --- a/app/src/js/components/Executions/index.js +++ b/app/src/js/components/Executions/index.js @@ -3,30 +3,55 @@ import React from 'react'; import { withRouter, Route, Switch } from 'react-router-dom'; import PropTypes from 'prop-types'; import Sidebar from '../Sidebar/sidebar'; +import DatePickerHeader from '../DatePickerHeader/DatePickerHeader'; import ExecutionOverview from './overview'; import ExecutionStatus from './execution-status'; import ExecutionLogs from './execution-logs'; +import ExecutionEvents from './execution-events'; +import { getCount, listExecutions } from '../../actions'; +import { strings } from '../locale'; class Executions extends React.Component { - render () { - return ( -
+ query () { + this.props.dispatch(getCount({ + type: 'executions', + field: 'status' + })); + this.props.dispatch(listExecutions()); + this.displayName = strings.executions; + } + + renderHeader () { + const { pathname } = this.props.location; + const showDatePicker = pathname === '/executions'; + + if (showDatePicker) { + return ; + } else { + return (
-

Executions

+

{strings.executions}

+ ); + } + } + + render () { + return ( +
+ {this.renderHeader()}
- + +
- - + + +
@@ -37,9 +62,8 @@ class Executions extends React.Component { } Executions.propTypes = { - children: PropTypes.object, - location: PropTypes.object, - params: PropTypes.object + dispatch: PropTypes.func, + location: PropTypes.object }; export default withRouter(Executions); diff --git a/app/src/js/components/Executions/overview.js b/app/src/js/components/Executions/overview.js index 80a59cfad..1ecbb5baa 100644 --- a/app/src/js/components/Executions/overview.js +++ b/app/src/js/components/Executions/overview.js @@ -26,6 +26,7 @@ import { collectionOptions } from '../../selectors'; import statusOptions from '../../utils/status'; +import pageSizeOptions from '../../utils/page-size'; import List from '../Table/Table'; import Dropdown from '../DropDown/dropdown'; import Search from '../Search/search'; @@ -33,6 +34,7 @@ import Overview from '../Overview/overview'; import _config from '../../config'; import { strings } from '../locale'; import { tableColumns } from '../../utils/table-config/executions'; +import ListFilters from '../ListActions/ListFilters'; const { updateInterval } = _config; @@ -98,39 +100,6 @@ class ExecutionOverview extends React.Component {

All Executions {count ? ` ${tally(count)}` : 0}

-
- - - - - - - -
- + > + + + + + + + + + + + +
); diff --git a/app/src/js/components/Form/Form.js b/app/src/js/components/Form/Form.js index fcd22ea97..a4ae42236 100644 --- a/app/src/js/components/Form/Form.js +++ b/app/src/js/components/Form/Form.js @@ -1,5 +1,7 @@ 'use strict'; + import React from 'react'; +import { createNextState } from '@reduxjs/toolkit'; import { generate } from 'shortid'; import { set } from 'object-path'; import slugify from 'slugify'; @@ -12,6 +14,8 @@ import SubForm from '../SubForm/sub-form'; import t from '../../utils/strings'; import PropTypes from 'prop-types'; import { window } from '../../utils/browser'; +import { isEmpty, isFinite } from 'lodash'; + const scrollTo = typeof window.scrollTo === 'function' ? window.scrollTo : () => true; export const formTypes = { @@ -27,7 +31,44 @@ export const defaults = { json: '{\n \n}' }; -const errorMessage = (errors) => `Please review ${errors.join(', ')} and submit again.`; +const errorMessage = (errors) => `Please review the following fields and submit again: ${errors.map(error => `'${error}'`).join(', ')}`; + +const generateDirty = (inputs) => + Object.entries(inputs).reduce( + (dirty, [id, { value }]) => + ({ ...dirty, [id]: isFinite(value) || !isEmpty(value) }), + {} + ); + +const generateComponentId = (label, id) => + slugify(label) + '-' + id; + +const generateInputState = (inputMeta, id) => { + const inputState = {}; + + inputMeta.forEach(({ schemaProperty, type, value, error, ...rest }) => { + const inputId = generateComponentId(schemaProperty, id); + + if (!value && value !== 0) { + switch (type) { + case formTypes.list: value = []; break; + case formTypes.subform: value = {}; break; + default: value = ''; + } + } + + inputState[inputId] = { + ...rest, + type, + value, + schemaProperty, + validationError: error, // this is the stored error message for the field + error: null // this is the displayed error for the field + }; + }); + + return inputState; +}; /** * Generates an HTML structure of form elements and @@ -42,147 +83,179 @@ const errorMessage = (errors) => `Please review ${errors.join(', ')} and submit export class Form extends React.Component { constructor (props) { super(props); - this.props = props; - this.displayName = 'Form'; - this.generateComponentId = this.generateComponentId.bind(this); - this.isInflight = this.isInflight.bind(this); + + // Generate ID for this form + this.id = generate(); this.onChange = this.onChange.bind(this); this.onCancel = this.onCancel.bind(this); this.onSubmit = this.onSubmit.bind(this); - this.scrollToTop = this.scrollToTop.bind(this); - // generate id for this form - this.id = generate(); + const inputs = generateInputState(props.inputMeta, this.id); + const dirty = generateDirty(inputs); - // initiate empty state for all inputs - const inputState = {}; - this.props.inputMeta.forEach(input => { - let inputId = this.generateComponentId(input.schemaProperty); - let value = input.value; - if (!value && value !== 0) { - value = input.type === formTypes.list ? [] - : input.type === formTypes.subform ? {} : ''; - } - let error = null; - inputState[inputId] = { value, error }; - }); this.state = { - inputs: inputState, - dirty: {}, - errors: [] + inputs, + dirty, + errors: [], + submitted: false }; } - generateComponentId (label) { - return slugify(label) + '-' + this.id; - } - isInflight () { return this.props.status && this.props.status === 'inflight'; } onChange (inputId, value) { - // update the internal key/value store, in addition to marking as dirty - const inputState = Object.assign({}, this.state.inputs); - set(inputState, [inputId, 'value'], value); + // Update the internal key/value store, in addition to marking as dirty + this.setState(createNextState((draftState) => { + draftState.inputs[inputId].value = value; + draftState.dirty[inputId] = true; + // validate the field for live changes + this.validateField({ + field: this.state.inputs[inputId], + inputId, + draftState + }); + })); + } - const markedDirty = Object.assign({}, this.state.dirty); - markedDirty[inputId] = true; + validateField ({ + field, + inputId, + draftState + }) { + const { dirty, inputs, errors } = draftState; + let { value } = inputs[inputId]; - this.setState(Object.assign({}, this.state, { - inputs: inputState, - dirty: markedDirty - })); + // don't set a value for values that haven't changed and aren't required + if (!dirty[inputId] && !field.required) return; + + // if expected type is json, validate as json first + if (field.type === formTypes.textArea && field.mode === 'json') { + try { + value = JSON.parse(value); + } catch (e) { + if (!errors.includes(field.labelText)) errors.push(field.labelText); + inputs[inputId].error = t.errors.json; + } + } else if (field.type === formTypes.number) { + try { + value = parseInt(value); + } catch (e) { + if (!errors.includes(field.labelText)) errors.push(field.labelText); + inputs[inputId].error = t.errors.integerRequired; + } + } + + if (field.validate && !field.validate(value)) { + if (!errors.includes(field.labelText)) errors.push(field.labelText); + const error = field.error || field.validationError || t.errors.generic; + inputs[inputId].error = error; + } else if (inputs[inputId].error) { + draftState.errors = errors.filter(item => item !== field.labelText); + delete inputs[inputId].error; + } } onCancel (e) { e.preventDefault(); - if (this.isInflight()) { return; } - this.props.cancel(this.props.id); + + if (!this.isInflight()) { + this.props.cancel(this.props.id); + } } onSubmit (e) { e.preventDefault(); - if (this.isInflight()) { return; } - const inputState = Object.assign({}, this.state.inputs); + if (this.isInflight()) return; // validate input values in the store - // if values pass validation, write to payload object - const errors = []; - const payload = {}; - this.props.inputMeta.forEach(input => { - let inputId = this.generateComponentId(input.schemaProperty); - let { value } = inputState[inputId]; - - // don't set a value for values that haven't changed - const markedDirty = this.state.dirty[inputId]; - if (!markedDirty) { - return; - } - // if expected type is json, validate as json first - if (input.type === formTypes.textArea && input.mode === 'json') { - try { - value = JSON.parse(value); - } catch (e) { - errors.push(input.schemaProperty); - return set(inputState, [inputId, 'error'], t.errors.json); - } - } + this.setState(createNextState((draftState) => { + this.props.inputMeta.forEach(field => { + const inputId = generateComponentId(field.schemaProperty, this.id); - if (input.type === formTypes.number) { - try { - value = parseInt(value); - } catch (e) { - errors.push(input.schemaProperty); - return set(inputState, [inputId, 'error'], t.errors.integerRequired); - } - } + this.validateField({ + field, + inputId, + draftState + }); + }); - if (input.validate && !input.validate(value)) { - errors.push(input.schemaProperty); - let error = input.error || t.errors.generic; - return set(inputState, [inputId, 'error'], error); - } else if (inputState[inputId].error) { - delete inputState[inputId].error; - } + draftState.submitted = true; + })); + } - // Ignore empty fields that aren't required - // These may have input elements in the form, but - // the API will fail if it's sent empty strings - if (value !== '' || input.required) { - set(payload, input.schemaProperty, value); - } - }); + submitPayload () { + const { inputs, errors } = this.state; + const payload = {}; - this.setState(Object.assign({}, this.state, { - inputs: inputState - })); + if (errors.length === 0) { + Object.entries(inputs).forEach(entry => { + const entryValue = entry[1]; + const { value, required, schemaProperty, type, mode } = entryValue; - if (errors.length) this.scrollToTop(); - else this.props.submit(this.props.id, payload); - this.setState({errors}); + if (required || + // only add an optional array when it is not empty + (Array.isArray(value) && value.length > 0) || + // only add an optional value when it is not an empty string or will create an empty object + (!Array.isArray(value) && (value !== '' && value !== '{}')) + ) { + let payloadValue = value; + // these should be safe since we've already validated at this point + if (type === formTypes.textArea && mode === 'json') { + payloadValue = JSON.parse(value); + } else if (type === formTypes.number) { + payloadValue = parseInt(value); + } + set(payload, schemaProperty, payloadValue); + } + }); + this.props.submit(this.props.id, payload); + } else { + this.scrollToTop(); + } + + this.setState({ submitted: false }); } scrollToTop () { if (this.DOMElement && typeof this.DOMElement.scrollIntoView === 'function') { this.DOMElement.scrollIntoView(true); - } else scrollTo(0, 0); + } else { + scrollTo(0, 0); + } + } + + componentDidUpdate (prevProps) { + const { inputMeta } = this.props; + + if (prevProps.inputMeta !== inputMeta) { + const inputs = generateInputState(inputMeta, this.id); + const dirty = generateDirty(inputs); + + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ inputs, dirty }); + } + + if (this.state.submitted) { + this.submitPayload(); + } } render () { const inputState = this.state.inputs; const { errors } = this.state; const { status } = this.props; + const form = (
{ this.DOMElement = element; }}> - {errors.length ? : null} + {errors.length > 0 && }
    - {this.props.inputMeta.map(form => { - let { type, label } = form; - - // decide which element to render + {this.props.inputMeta.map(input => { + const { type, label } = input; let element; + switch (type) { case formTypes.textArea: element = TextAreaForm; @@ -202,22 +275,23 @@ export class Form extends React.Component { break; } - // retrieve value and errors stored in state - let inputId = this.generateComponentId(form.schemaProperty); + const inputId = generateComponentId(input.schemaProperty, this.id); let { value, error } = inputState[inputId]; + // coerce non-null values to string to simplify proptype warnings on numbers if (type !== formTypes.list && type !== formTypes.subform && !value && value !== 0) { value = String(value); } + // dropdowns have options - let options = type === formTypes.dropdown && form.options || null; + const options = (type === formTypes.dropdown && input.options) || null; // textarea forms pass a mode value to ace - const mode = type === formTypes.textArea && form.mode || null; + const mode = (type === formTypes.textArea && input.mode) || null; // subforms have fieldsets that define child form structure - const fieldSet = type === formTypes.subform && form.fieldSet || null; - + const fieldSet = (type === formTypes.subform && input.fieldSet) || null; // text forms can be type=password or number - let textType = (type === formTypes.text && form.isPassword) ? 'password' : null; + let textType = (type === formTypes.text && input.isPassword) ? 'password' : null; + if (type === formTypes.number) { textType = 'number'; } @@ -233,26 +307,41 @@ export class Form extends React.Component { type: textType, onChange: this.onChange }); + return
  • {elem}
  • ; })}
- {this.props.submit ? ( + {this.props.submit && ( - ) : null} + > + {this.isInflight() ? 'Loading...' : status === 'success' ? 'Success!' : 'Submit'} + + )} - {this.props.cancel ? ( + {this.props.cancel && ( - ) : null} + > + Cancel + + )}
); - return this.props.nowrap ? form :
{form}
; + + return this.props.nowrap + ? form + : ( +
+ {form} +
+ ); } } diff --git a/app/src/js/components/Form/_form.scss b/app/src/js/components/Form/_form.scss index fa624c1e3..9eec3ce51 100644 --- a/app/src/js/components/Form/_form.scss +++ b/app/src/js/components/Form/_form.scss @@ -22,6 +22,10 @@ input[type="checkbox"], input[type="radio"] { font-size: 1em; } +::placeholder{ + color: $lighter-grey; +} + label { display: block; font-weight: $base-font-semibold; @@ -377,6 +381,7 @@ select option{ .dropdown__wrapper { width: 50%; margin-top: .2em; + float: left; select { width: 100%; } @@ -413,6 +418,13 @@ select option{ } } +.form__error--wrapper { + input, + select { + border-color: $error-red; + } +} + .form__error { font-size: 1em; display: block; @@ -502,4 +514,71 @@ select option{ .subform__name { display: none; } +} + +.rule__status { + display: flex; + flex-direction: row; + align-items: center; + flex: 12; +} + +.rule__content, +.execution__content { + border: 1px solid #eceaea; + box-shadow: $shadow__default; + border-radius: 10px; + padding: 25px 40px; + margin-top: 15px; + + .metadata__details { + font-size: $base-font-size; + display: flex; + flex-direction: column; + + .meta__row { + flex-direction: row; + dt{ + width: 10%; + } + } + } +} + +.execution__content { + cursor: pointer; + + .execution__content--visual { + width: 25%; + margin: 0 auto; + } + + .metadata__details .meta__row { + dd { + width: 80%; + margin: 0; + } + + dt { + width: 20%; + padding: 0; + } + } +} + +.execution__visual { + .header { + display: flex; + justify-content: space-between; + color: white; + padding: .5em 2em; + box-shadow: $shadow__default; + border-radius: 10px 10px 0 0; + cursor: pointer; + } + + .execution__content { + margin-top: 0; + border-radius: 0 0 10px 10px; + } } \ No newline at end of file diff --git a/app/src/js/components/FormSchema/schema.js b/app/src/js/components/FormSchema/schema.js index 548814b47..fb5fff039 100644 --- a/app/src/js/components/FormSchema/schema.js +++ b/app/src/js/components/FormSchema/schema.js @@ -1,21 +1,34 @@ 'use strict'; + import React from 'react'; import PropTypes from 'prop-types'; import { get, set } from 'object-path'; import { Form, formTypes } from '../Form/Form'; -import { isText, isNumber, isArray, arrayWithLength } from '../../utils/validate'; +import { + arrayWithLength, + isArray, + isNumber, + isObject, + isText +} from '../../utils/validate'; import t from '../../utils/strings'; import ErrorReport from '../Errors/report'; +import { startCase } from 'lodash'; + const { errors } = t; -export const traverseSchema = function (schema, fn, path) { - for (let property in schema.properties) { +const traverseSchema = (schema, enums, fn, path = []) => { + for (const property in schema.properties) { const meta = schema.properties[property]; - if (meta.type === 'object' && meta.hasOwnProperty('properties')) { - const nextPath = path ? path + '.' + property : property; - traverseSchema(meta, fn, nextPath); - } else { - fn(property, meta, schema, path); + + if ( + meta.type !== 'object' || + meta.additionalProperties === true || + (property in enums) + ) { + fn([...path, property], meta, schema); + } else if (typeof meta.properties === 'object') { + traverseSchema(meta, enums, fn, [...path, property]); } } }; @@ -36,67 +49,86 @@ export const traverseSchema = function (schema, fn, path) { export const removeReadOnly = function (data, schema) { const readOnlyRemoved = {}; const schemaFields = []; - traverseSchema(schema, function (property, meta, schemaProperty, path) { - schemaFields.push(property); + + traverseSchema(schema, {}, (path, meta) => { + schemaFields.push(path[path.length - 1]); + if (!meta.readonly) { - const accessor = path ? path + '.' + property : property; - set(readOnlyRemoved, accessor, get(data, accessor)); + set(readOnlyRemoved, path, get(data, path)); } }); // filter fields that are not in the schema const esFields = ['queriedAt', 'timestamp', 'stats']; - const nonSchemaFields = Object.keys(data).filter(f => ( - schemaFields.indexOf(f) === -1 && esFields.indexOf(f) === -1) + const nonSchemaFields = Object.keys(data).filter( + (f) => !schemaFields.includes(f) && !esFields.includes(f) ); // add them to the list of fields - nonSchemaFields.forEach(f => set(readOnlyRemoved, f, get(data, f))); + nonSchemaFields.forEach((f) => set(readOnlyRemoved, f, get(data, f))); return readOnlyRemoved; }; // recursively scan a schema object and create a form config from it. // returns a flattened representation of the schema. -export const createFormConfig = function (data, schema, include) { +export const createFormConfig = function ( + data, + schema, + include, + exclude, + enums = {} +) { data = data || {}; const fields = []; - traverseSchema(schema, function (property, meta, schemaProperty, path) { - // If a field isn't user-editable, hide it from the form - if (meta.readonly) { return; } + const toRegExps = (stringsOrRegExps) => + stringsOrRegExps.map((strOrRE) => + typeof strOrRE === 'string' ? new RegExp(`^${strOrRE}$`, 'i') : strOrRE + ); + const inclusions = toRegExps(include); + const exclusions = toRegExps(exclude); + const matches = (string) => (regexp) => regexp.test(string); + const includeProperty = (path) => + inclusions.some(matches(path)) && !exclusions.some(matches(path)); - // create an object-path-ready accessor string - const accessor = path ? path + '.' + property : property; + traverseSchema(schema, enums, (path, meta, schemaProperty) => { + const fullyQualifiedProperty = path.join('.'); + + // If a field isn't user-editable, hide it from the form + if (meta.readonly) return; // if there are included properties, only create forms for those - if (Array.isArray(include) && include.indexOf(accessor) === -1) { return; } + if (!includeProperty(fullyQualifiedProperty)) return; // determine the label - const required = Array.isArray(schemaProperty.required) && - schemaProperty.required.indexOf(property) >= 0; + const property = path[path.length - 1]; + const required = isArray(schemaProperty.required) && schemaProperty.required.includes(property); + const labelText = startCase(meta.title || property); const label = ( - { path ? {path + ' - '} : null } - {meta.title || property} - { required ? * : null } - { meta.description ? ({meta.description}) : null } + {labelText} + {required && *} + {meta.description && ( + ({meta.description}) + )} ); - const value = get(data, accessor) || get(meta, 'default'); + const value = get(data, path, get(meta, 'default')); const config = { - value, label, - schemaProperty: accessor, - required: required + value, + label, + labelText, + schemaProperty: fullyQualifiedProperty, + required }; // dropdowns have type set to string, but have an enum prop. // use enum as the type instead of string. - const type = Array.isArray(meta['enum']) ? 'enum' - : meta.hasOwnProperty('patternProperties') ? 'pattern' : meta.type; + const type = isArray(meta.enum) || property in enums ? 'enum' : Object.prototype.hasOwnProperty.call(meta, 'patternProperties') ? 'pattern' : meta.type; switch (type) { - case 'pattern': + case 'pattern': { // pattern fields are an abstraction on arrays of objects. // each item in the array will be a grouped set of field inputs. @@ -110,32 +142,46 @@ export const createFormConfig = function (data, schema, include) { config.type = formTypes.subform; fields.push(config); break; - case 'enum': + } + case 'enum': { // pass the enum fields as options - config.options = meta.enum; - fields.push(dropdown(config, property, (required && isText))); + config.options = meta.enum || enums[property]; + fields.push(dropdownField(config, property, required && isText)); break; - case 'array': + } + case 'array': { // some array types have a minItems property - let validate = !required ? null - : (meta.minItems && isNaN(meta.minItems)) - ? arrayWithLength(+meta.minItems) : isArray; - fields.push(list(config, property, validate)); + const validate = !required ? false : (meta.minItems && isNaN(meta.minItems)) ? arrayWithLength(+meta.minItems) : isArray; + fields.push(listField(config, property, validate)); break; + } case 'string': - fields.push(textfield(config, property, (required && isText))); + fields.push(textField(config, property, required && isText)); break; case 'integer': case 'number': - fields.push(numberfield(config, property, (required && isNumber))); + fields.push(numberField(config, property, required && isNumber)); + break; + case 'object': + fields.push(textAreaField(config, property, required && isObject)); break; - default: return; + default: } }); + return fields; }; -function textfield (config, property, validate) { +const textAreaField = (config, property, validate) => ({ + ...config, + type: formTypes.textArea, + mode: 'json', + value: isObject(config.value) ? JSON.stringify(config.value, null, 2) : config.value, + validate: validate, + error: validate && get(errors, property, errors.required) +}); + +function textField (config, property, validate) { config.type = formTypes.text; config.validate = validate; config.error = validate && get(errors, property, errors.required); @@ -144,7 +190,7 @@ function textfield (config, property, validate) { return config; } -function numberfield (config, property, validate) { +function numberField (config, property, validate) { config.type = formTypes.number; config.validate = validate; config.error = validate && get(errors, property, errors.required); @@ -152,14 +198,14 @@ function numberfield (config, property, validate) { return config; } -function dropdown (config, property, validate) { +function dropdownField (config, property, validate) { config.type = formTypes.dropdown; config.validate = validate; config.error = validate && get(errors, property, errors.required); return config; } -function list (config, property, validate) { +function listField (config, property, validate) { config.type = formTypes.list; config.validate = validate; config.error = validate && get(errors, property, errors.required); @@ -169,24 +215,32 @@ function list (config, property, validate) { export class Schema extends React.Component { constructor (props) { super(props); - this.props = props; - const { schema, data, include } = this.props; - this.state = { fields: createFormConfig(data, schema, include) }; + + const { schema, data, include, exclude, enums } = props; + + this.state = { + fields: createFormConfig(data, schema, include, exclude, enums) + }; } componentDidUpdate (prevProps) { - const { schema, data, include, pk } = this.props; - if (prevProps.pk !== pk) { - this.setState({ fields: createFormConfig(data, schema, include) }); // eslint-disable-line react/no-did-update-set-state + const { schema, data, include, exclude, pk, enums } = this.props; + + if (prevProps.pk !== pk || prevProps.enums !== enums || prevProps.data !== data) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + fields: createFormConfig(data, schema, include, exclude, enums) + }); } } render () { const { fields } = this.state; const { error } = this.props; + return ( -
- {error ? : null} +
{ error && element && element.scrollIntoView(true); }}> + {error && }
{ + const [showModal, setShowModal] = useState(false); + const [query, setQuery] = useState(JSON.stringify(defaultQuery, null, 2)); + const [errorState, setErrorState] = useState(); + const [requestId] = useState(Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)); + const status = get(granules.bulk, [requestId, 'status']); + const error = get(granules.bulk, [requestId, 'error']) || errorState; + const asyncOpId = get(granules.bulk, [requestId, 'data', 'id']); + const inflight = status === 'inflight'; + const success = status === 'success'; + const ButtonComponent = element; + + const buttonClass = `button button--small form-group__element button--green + ${inflight ? ' button--loading' : ''} + ${className ? ` ${className}` : ''}`; - cancel (e) { - this.setState({ modal: false }); + const elementClass = `async__element + ${inflight ? ' async__element--loading' : ''} + ${className ? ` ${className}` : ''}`; + + const buttonText = inflight ? 'loading...' + : success ? 'Success!' : 'Run Bulk Granules'; + + function handleCancel (e) { + setShowModal(false); } - submit (e) { + function handleSubmit (e) { e.preventDefault(); - const requestId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); - let { query } = this.state; - this.setState({requestId}); - if (this.props.status !== 'inflight') { + if (status !== 'inflight') { try { var json = JSON.parse(query); } catch (e) { - return this.setState({ error: 'Syntax error in JSON' }); + return setErrorState('Syntax error in JSON'); } - this.props.dispatch(bulkGranule({requestId, json})); + dispatch(bulkGranule({ requestId, json })); } } - buttonClass (processing) { - let className = 'button button--small form-group__element button--green'; - if (processing) className += ' button--loading'; - if (this.props.className) className += ' ' + this.props.className; - return className; - } - - // a generic className generator for non-button elements - elementClass (processing) { - let className = 'async__element'; - if (processing) className += ' async__element--loading'; - if (this.props.className) className += ' ' + this.props.className; - return className; - } - - handleClick (e) { + function handleClick (e) { e.preventDefault(); - if (this.props.confirmAction) { - this.setState({ modal: true }); + if (confirmAction) { + setShowModal(true); } } - onChange (id, value) { - this.setState({ query: value }); + function onChange (id, value) { + setQuery(value); } - render () { - const { requestId, query, modal } = this.state; - const defaultValue = { - workflowName: '', - index: '', - query: '' - }; - const queryValue = query || JSON.stringify(defaultValue, null, 2); - const status = get(this.props.state.bulk, [requestId, 'status']); - const error = get(this.props.state.bulk, [requestId, 'error']) || this.state.error; - const buttonText = status === 'inflight' ? 'loading...' - : status === 'success' ? 'Success!' : 'Run Bulk Granules'; - const inflight = status === 'inflight'; - const props = { - className: this.props.element ? this.elementClass(inflight) : this.buttonClass(inflight), - onClick: this.handleClick - }; - const text = 'Run Bulk Granules'; - const children = ( - - {text}{inflight ? : ''} - - ); - const element = this.props.element || 'button'; - const button = React.createElement(element, props, children); - if (status === 'success') { - const asyncOpId = get(this.props.state.bulk, [requestId, 'data', 'id']); - return ( -
- { button } - {/* Once the new Bootstrap Modal is working per the built in functionality */} - { modal &&
} -
- - - Bulk Granules -

- Your request to process a bulk granules operation has been submitted.
- ID {asyncOpId} -

-
- - - Go To Operations - - -
-
-
- ); - } - return ( -
- { button } - {/* Once the new Bootstrap Modal is working per the built in functionality */} - { modal &&
} -
- - - Bulk Granules - -

To run and complete your bulk granule task:

-

- 1. In the box below, enter the workflowName.
- 2. Then add either an array of granule Ids or an elasticsearch query and index.
-

-
-

If you need to construct a query

-

- To construct a query, go to Kibana and run a search. Then place the elasticsearch query in the operation input.
- -

-
- -