diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000000..cedbd17b47 --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,5 @@ +> 0.5% +last 2 versions +Firefox ESR +ie 11 +not dead diff --git a/.dependabot/config.yml b/.dependabot/config.yml new file mode 100644 index 0000000000..955bb70057 --- /dev/null +++ b/.dependabot/config.yml @@ -0,0 +1,25 @@ +version: 1 + +update_configs: + - package_manager: 'javascript' + directory: '/' + update_schedule: 'daily' + version_requirement_updates: 'increase_versions' + target_branch: 'master' + ignored_updates: + # ui-core supports 16.8 of react, so ignore all updates + - match: + dependency_name: 'react' + version_requirement: '>=16.9.x' + - match: + dependency_name: 'react-dom' + version_requirement: '>=16.9.x' + + automerged_updates: + - match: + dependency_name: '@dhis2/*' + dependency_type: 'all' + update_type: 'semver:minor' + - match: + dependency_type: 'all' + update_type: 'security:patch' diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..57becb8750 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# For more information about the properties used in +# this file, please see the EditorConfig documentation: +# https://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..4a05976ff8 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +**/src/locales/* +**/cypress/assets diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..7ff770b694 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,5 @@ +const { config } = require('@dhis2/cli-style') + +module.exports = { + extends: [config.eslintReact], +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..2efd4b9953 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# match all files, request review from +* @dhis2/team-apps diff --git a/.github/semantic.yml b/.github/semantic.yml new file mode 100644 index 0000000000..555c025250 --- /dev/null +++ b/.github/semantic.yml @@ -0,0 +1,4 @@ +titleOnly: false +commitsOnly: false +titleAndCommits: true +allowMergeCommits: false diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000..0d0b1c994d --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1 @@ +_extends: .github diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml new file mode 100644 index 0000000000..39b3e5270b --- /dev/null +++ b/.github/workflows/cypress.yml @@ -0,0 +1,39 @@ +# For more options: +# https://github.com/cypress-io/github-action + +name: 'dhis2: test (cypress)' + +on: + push: + branches: + - master + pull_request: + +env: + SERVER_START_CMD: 'yarn cy:server' + SERVER_URL: 'http://localhost:5000' + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + +jobs: + e2e: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" + strategy: + matrix: + containers: [1, 2, 3] + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Tests + uses: cypress-io/github-action@v1 + with: + record: true + parallel: true + start: ${{ env.SERVER_START_CMD }} + wait-on: ${{ env.SERVER_URL }} + wait-on-timeout: 60 + group: 'e2e' + env: + CI: true + STORYBOOK_TESTING: true diff --git a/.github/workflows/node-lint.yml b/.github/workflows/node-lint.yml new file mode 100644 index 0000000000..6f3dfd4dc5 --- /dev/null +++ b/.github/workflows/node-lint.yml @@ -0,0 +1,21 @@ +name: 'dhis2: lint (node)' + +on: push + +jobs: + check: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: 12.x + + - name: Install + run: yarn install --frozen-lockfile + + - name: Run linters + run: yarn lint + env: + CI: true diff --git a/.github/workflows/node-publish.yml b/.github/workflows/node-publish.yml new file mode 100644 index 0000000000..f1a015b240 --- /dev/null +++ b/.github/workflows/node-publish.yml @@ -0,0 +1,50 @@ +name: 'dhis2: publish (node)' + +on: + push: + branches: + # match branches in: + # https://github.com/semantic-release/semantic-release/blob/master/docs/usage/configuration.md#branches + - master + - next + - next-major + - alpha + - beta + - '[0-9]+.x' + - '[0-9]+.x.x' + - '[0-9]+.[0-9]+.x' + +env: + GIT_AUTHOR_NAME: '@dhis2-bot' + GIT_AUTHOR_EMAIL: 'apps@dhis2.org' + GIT_COMMITTER_NAME: '@dhis2-bot' + GIT_COMMITTER_EMAIL: 'apps@dhis2.org' + NPM_TOKEN: ${{secrets.NPM_TOKEN}} + GH_TOKEN: ${{secrets.GH_TOKEN}} + +jobs: + publish: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: 12.x + + - name: Install + run: yarn install --frozen-lockfile + + - name: Build + run: yarn build + + - name: Test + run: yarn test + + - name: Lint + run: yarn lint + + - name: Publish to NPM + run: npx @dhis2/cli-utils release --publish npm + env: + CI: true diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml new file mode 100644 index 0000000000..a6efab52c2 --- /dev/null +++ b/.github/workflows/node-test.yml @@ -0,0 +1,24 @@ +name: 'dhis2: test (node)' + +on: push + +jobs: + unit: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: 12.x + + - name: Install + run: yarn install --frozen-lockfile + + - name: Build + run: yarn build + + - name: Test + run: yarn test + env: + CI: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..155d67f4aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +.yarn +.yarnrc + +# Editor settings +/.vscode + +# dependencies +node_modules/ + +# testing +/coverage + +# production +build/ +dist/ + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +bundle.stats.json + +# cypress +cypress/screenshots +cypress/videos +cypress.env.json + +# d2 +.d2/ +locales/ diff --git a/.huskyrc.js b/.huskyrc.js new file mode 100644 index 0000000000..679a230050 --- /dev/null +++ b/.huskyrc.js @@ -0,0 +1,15 @@ +const { config } = require('@dhis2/cli-style') +const husky = require(config.husky) + +const tasks = arr => arr.join(' && ') + +module.exports = { + hooks: { + ...husky.hooks, + 'pre-commit': tasks([ + 'd2-style js check --staged', + 'd2-style text check --staged', + ]), + 'pre-push': 'yarn test', + }, +} diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000000..def7934e3d --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,5 @@ +const { config } = require('@dhis2/cli-style') + +module.exports = { + ...require(config.prettier), +} diff --git a/.tx/config b/.tx/config new file mode 100644 index 0000000000..9f41fc5450 --- /dev/null +++ b/.tx/config @@ -0,0 +1,10 @@ +[main] +host = https://www.transifex.com +lang_map = fa_AF: prs + +[app-ui-widgets.en-pot] +file_filter = packages/widgets/i18n/.po +minimum_perc = 0 +source_file = packages/widgets/i18n/en.pot +source_lang = en +type = PO diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..344c52a0fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2018, DHIS 2 +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md index 7515df29eb..40efa6c797 100644 --- a/README.md +++ b/README.md @@ -1 +1,206 @@ -# ui +# ui-core + +[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) + +**[Online docs and demos (latest master build)](https://ui-core.dhis2.nu/)** + +## Requires polyfills + +These are included with apps generated with Create React App, but if you +want to use this library with the environments in `.browserslistrc` you +need to polyfill the following: + +- web.dom.iterable +- es6.array.fill +- es6.array.iterator +- es6.function.name +- es6.object.assign +- es6.object.keys +- es6.object.set-prototype-of +- es6.promise +- es6.regexp.match +- es6.regexp.split +- es6.string.starts-with +- es6.string.iterator +- es6.symbol +- es7.symbol.async-iterator + +### Review + +Make a PR from the component branch. Add a team member who can review +the code and example for both **LTR** and **RTL** modes. + +## Development + +We use [Storybook](https://storybook.js.org) that is excellent for +testing components while developing them. + +``` +yarn install +yarn start +``` + +The Storybook runs on localhost:5000. + +### Apply the code style + +This library follows the [dhis2 +code-style](https://github.com/dhis2/cli-style). There is a commit hook +that will apply the code style to staged files, but if you want to do +this manually during development, run: + +``` +yarn format +``` + +### Commit messages + +This library follows the commit message style defined in [@commitlint/config-conventional](https://www.npmjs.com/package/@commitlint/config-conventional#rules). There is a pre-commit hook that will stop commits that don't follow the convention. + +### Releasing + +When a PR is merged to the **master** branch, the release process +automatically starts. + +It is very important that the commit that lands on **master** follows +the conventional commit format, since that is what is used to +automatically determine the next version. + +**Never push straight to master, always go through a PR!** + +## Testing + +Testing is done with cypress & cucumber. + +- Run `yarn cypress:run`
+ This will run cypress and exit with either 0 or 1 + +- Run `yarn cypress:open`
+ This will open the cypress gui, which is useful for writing tests + +### Recording videos and taking screenshots + +When running `yarn cypress:run`, by default no video is recorded and no +screenshot will be taken. + +- Recording videos can be enabled by supplying the + `CYPRESS_VIDEO=true` env var. +- Taking screenshots can be enabled by supplying the + `CYPRESS_SCREENSHOT=true` env var. + +### Storybook stories for testing + +Sometimes it's required to add stateful stories to test certain behavior. +That's why you can add files with the following file name format: `*.stories.testing.js` +These stories will not be used when generating the docs storybook and can +contain more sophisticated scenarios for testing. + +## FAQ + +### How to avoid a global style rule from affecting a ui-core component? + +The best practice is that each component has styles that are scoped to +that component to avoid style rules that leak out from one component and +bleeds into another component. + +Generally all the CSS-in-JS solutions grant this out of the box, and +this is only a problem when using global stylesheets, e.g. by importing +`index.css` in an application and adding global rules to it. + +There are two ways to work around the problem. + +#### Example CSS + +Given the following CSS: + +`index.css`: + +``` +div { + padding: 20px; +} + +.disabled { + opacity: 0.5; +} +``` + +These styles will bleed into all components that use `div` or +`.disabled` and doesn't itself set those rules and win by specificity. + +If a rule is not set by the component, the technique that overrides +through the use of `className` still applies, it's just "softer" and +does not require `!important` to counter the rules. + +If the rules do something `!important`, the only course of action is to +counter it with another `!important` rule. Given the following CSS: + +`index.css`: + +``` +div { + padding: 20px !important; +} + +.disabled { + opacity: 0.5 !important; +} +``` + +Now there is no way for specificity to win, and all components that use +those classes or elements will inherit those rules. + +#### Cascading override + +By using a cascading override, the style bleed can be stopped by +utilizing a wrapper element for the component, and this will cascade +down to the children of the component as well. The technique allows for +a workaround which does not affect the component itself, and is easily +removed when the App no longer uses leaky styles. + +Before: + +```js-jsx +
+ {...} + +
+``` + +After: + +```js-jsx +
+ {...} +
+ +
+
+``` + +```css +.fix-container .disabled { + /* will undo the overriding of global styles for this component */ + opacity: 1 !important; +} +``` + +#### Class name override + +If you cannot control the CSS that bleeds into `ui` components, then +you need to define a class that counters the effects of the rule, and +use the `className` prop to override the global rule. + +`index.css`: + +``` +.fix { + opacity: 1 !important; + padding: 10px !important; +} +``` + +Pass that to the component through `className='fix'`, and it should +negate the troublesome CSS. Once the global rules in the App has been +removed, it is possible to remove the `className='fix'` as well. This +should be considered a temporary measure. diff --git a/config/jest/enzymeSetup.js b/config/jest/enzymeSetup.js new file mode 100644 index 0000000000..3d6cd1d53a --- /dev/null +++ b/config/jest/enzymeSetup.js @@ -0,0 +1,4 @@ +import { configure } from 'enzyme' +import Adapter from 'enzyme-adapter-react-16' + +configure({ adapter: new Adapter() }) diff --git a/cypress-cucumber-preprocessor.config.js b/cypress-cucumber-preprocessor.config.js new file mode 100644 index 0000000000..42ae088fe8 --- /dev/null +++ b/cypress-cucumber-preprocessor.config.js @@ -0,0 +1,3 @@ +module.exports = { + nonGlobalStepDefinitions: true, +} diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000000..566bc43c44 --- /dev/null +++ b/cypress.json @@ -0,0 +1,6 @@ +{ + "baseUrl": "http://localhost:5000", + "testFiles": "**/*.feature", + "video": false, + "projectId": "vyavbk" +} diff --git a/cypress/.eslintrc.js b/cypress/.eslintrc.js new file mode 100644 index 0000000000..aff433be63 --- /dev/null +++ b/cypress/.eslintrc.js @@ -0,0 +1,10 @@ +const config = require('../.eslintrc.js') + +module.exports = { + ...config, + env: { es6: true }, + globals: { + Cypress: 'readonly', + cy: 'readonly', + }, +} diff --git a/cypress/assets/unfetch.umd.js b/cypress/assets/unfetch.umd.js new file mode 100644 index 0000000000..3b3ed598b8 --- /dev/null +++ b/cypress/assets/unfetch.umd.js @@ -0,0 +1,82 @@ +/** + * See + * + * * https://github.com/cypress-io/cypress/issues/95 + * * https://github.com/cypress-io/cypress-example-recipes/tree/master/examples/stubbing-spying__window-fetch + * * https://github.com/cypress-io/cypress-example-recipes/blob/master/examples/stubbing-spying__window-fetch/cypress/integration/polyfill-fetch-from-tests-spec.js + * + * for an explanation why this is currently necessary... + */ +!(function(e, n) { + 'object' == typeof exports && 'undefined' != typeof module + ? (module.exports = n()) + : 'function' == typeof define && define.amd + ? define(n) + : (e.unfetch = n()) +})(this, function() { + return function(e, n) { + return ( + (n = n || {}), + new Promise(function(t, o) { + var r = new XMLHttpRequest(), + s = [], + u = [], + i = {}, + f = function() { + return { + ok: 2 == ((r.status / 100) | 0), + statusText: r.statusText, + status: r.status, + url: r.responseURL, + text: function() { + return Promise.resolve(r.responseText) + }, + json: function() { + return Promise.resolve( + JSON.parse(r.responseText) + ) + }, + blob: function() { + return Promise.resolve(new Blob([r.response])) + }, + clone: f, + headers: { + keys: function() { + return s + }, + entries: function() { + return u + }, + get: function(e) { + return i[e.toLowerCase()] + }, + has: function(e) { + return e.toLowerCase() in i + }, + }, + } + } + for (var a in (r.open(n.method || 'get', e, !0), + (r.onload = function() { + r + .getAllResponseHeaders() + .replace(/^(.*?):[^\S\n]*([\s\S]*?)$/gm, function( + e, + n, + t + ) { + s.push((n = n.toLowerCase())), + u.push([n, t]), + (i[n] = i[n] ? i[n] + ',' + t : t) + }), + t(f()) + }), + (r.onerror = o), + (r.withCredentials = 'include' == n.credentials), + n.headers)) + r.setRequestHeader(a, n.headers[a]) + r.send(n.body || null) + }) + ) + } +}) diff --git a/cypress/drafts/Menu/position.feature b/cypress/drafts/Menu/position.feature new file mode 100644 index 0000000000..d8318f564f --- /dev/null +++ b/cypress/drafts/Menu/position.feature @@ -0,0 +1,51 @@ +Feature: Position of a menu component + + # default max width: 380px; default max height: 380px + Background: + Given the menu component has a height and width of its default maximum + + Scenario: Default rendering + Given there is enough space between the anchor's bottom and the body's bottom to fit the default maximum + When the menu is opened + Then the menu is below the anchor + And the left of the menu is aligned with the left of the anchor + + Scenario: Flipped vertically + Given there is not enough space between the anchor's bottom and the body's bottom to fit the default maximum + And there is not enough space between the anchor's top and the body's top to fit the default maximum + When the menu is opened + Then the menu is above the anchor + And the left of the menu is aligned with the left of the anchor + + Scenario: Less than 368px and more than 50px available space below and above anchor + Given there is not enough space between the anchor's bottom and the body's bottom to fit the default maximum + And there is not enough space between the anchor's top and the body's top to fit the default maximum + But there is more than 50px of available space between the anchor's bottom and the body's bottom + When the menu is opened + Then the menu is below the anchor + And the height of the menu is reduced to fit + + # ¯\_(ツ)_/¯ + # This will cause the menu always to be off screen, but that's the apps fault + Scenario: Less than 50px available space below and above anchor + Given there is not enough space between the anchor's top and the body's top to fit the default maximum + And there is not enough space between the anchor's bottom and the body's bottom to fit the default maximum + When the menu is opened + Then the menu is below the anchor + And the heght of the menu is not reduced below 50px + + Scenario: Flipped horizontally + Given the space between the anchor's right and the body's right is less than the horizontal minimum space + And the space between the anchor's left and the body's left is at least the horizontal minimum space + When the menu is opened + Then the right of the menu is aligned with the right of the anchor + And the menu is below the anchor + + # ¯\_(ツ)_/¯ + # This will cause the menu always to be off screen, but that's the apps fault + Scenario: Forced body overflow + Given the space between the anchor's right and the body's right is less than the horizontal minimum space + And the space between the anchor's left and the body's left is less than the horizontal minimum space + When the menu is opened + Then the left of the menu is aligned with the left of the anchor + And the menu is rendered below the anchor diff --git a/cypress/drafts/Popover/position.feature b/cypress/drafts/Popover/position.feature new file mode 100644 index 0000000000..70b8df5165 --- /dev/null +++ b/cypress/drafts/Popover/position.feature @@ -0,0 +1,31 @@ +Feature: Popover positioning + + Background: + Given the popover has a width of 360px and height of 200px + + Scenario: Spacing between anchor and popover + When the anchor is clicked + Then there is some space between the anchor and the popover + + Scenario: Default positioning + Given there is enough space between the anchor's top and the body's top to fit the Popover + When the anchor is clicked + Then the popover is rendered above the the anchor + And the horizontal center of the popover is aligned with the horizontal center of the anchor + + Scenario: Flipped vertical + Given there is not enough space between the anchor's top and the body's top to fit the Popover + And there is enough space between the anchor's bottom and the body's bottom to fit the Popover + When the anchor is clicked + Then the popover is rendered below the anchor + And the horizontal center of the popover is aligned with the horizontal center of the anchor + + Scenario: Adjusted width + Given there is not enough space between the anchor's top and the body's top to fit the Popover + And there is not enough space between the anchor's bottom and the body's bottom to fit the Popover + When the anchor is clicked + Then the popover is rendered above the anchor + And the horizontal center of the popover is aligned with the horizontal center of the anchor + And the popover width is reduced to fit in the available space + + #!!Note: this is a exact copy of tooltip positioning diff --git a/cypress/drafts/Select/position.feature b/cypress/drafts/Select/position.feature new file mode 100644 index 0000000000..b0a487a0e1 --- /dev/null +++ b/cypress/drafts/Select/position.feature @@ -0,0 +1,25 @@ +Feature: Position of select menu dropdown + + Background: + Given the select menu dropdown has a height of 368px + And the select menu dropdown has a width of 280px + + Scenario: Default rendering + Given there is enough space between the anchor's bottom and the body's bottom to fit the Select's menu + When the menu is opened + Then it is rendered below the select + And the left of the select is aligned with the left of the anchor + + Scenario: Flipped rendering when insufficient space below + Given there is not enough space between the anchor's bottom and the body's bottom to fit the Select's menu + And there is enough space between the anchor's top and the body's top to fit the Select's menu + When the menu is opened + Then it is rendered above the select + And the left of the select is aligned with the left of the anchor + + Scenario: A select with less than 368px available space below and above + Given there is not enough space between the anchor's bottom and the body's bottom to fit the Select's menu + And there is not enough space between the anchor's top and the body's top to fit the Select's menu + When the menu is opened + Then it is rendered below the select + And the height of the select dropdown menu is reduced to fit within the element diff --git a/cypress/drafts/Tooltip/position.feature b/cypress/drafts/Tooltip/position.feature new file mode 100644 index 0000000000..0c60451986 --- /dev/null +++ b/cypress/drafts/Tooltip/position.feature @@ -0,0 +1,38 @@ +Feature: Tooltip positioning + + Background: + Given the tooltip has a width of 300px and height of 28px + + Scenario: Spacing between anchor and tooltip + When the anchor is clicked + Then there is some space between the anchor and the tooltip + + Scenario: Default positioning + Given there is enough space between the anchor's top and the body's top to fit the Tooltip + When the anchor is clicked + Then the tooltip is rendered above the the anchor + And the horizontal center of the tooltip is aligned with the horizontal center of the anchor + + Scenario: Flipped vertical + Given there is not enough space between the anchor's top and the body's top to fit the Tooltip + And there is enough space between the anchor's bottom and the body's bottom to fit the Tooltip + When the anchor is clicked + Then the tooltip is rendered below the anchor + And the horizontal center of the tooltip is aligned with the horizontal center of the anchor + + Scenario: Adjusted horiztonal position + Given there is enough space between the anchor's top and the body's top to fit the Tooltip + And there is enough space between the body's left and the body's right to fit the Tooltip + And the Tooltip doesn't use more than 50% of the space between the body's sides and the anchor's sides + When the anchor is clicked + Then the tooltip is rendered above the anchor + And the horizontal center of the tooltip is aligned with the horizontal center of the anchor + + Scenario: Adjusted width + Given there is enough space between the anchor's top and the body's top to fit the Tooltip + And there is enough space between the body's left and the body's right to fit the Tooltip + And the Tooltip does use more than 50% of the space between the body's sides and the anchor's sides + When the anchor is clicked + Then the tooltip is rendered above the anchor + And the horizontal center of the tooltip is aligned with the horizontal center of the anchor + But the tooltip's width is reducesd to only take 50% of the space available next to the anchor diff --git a/cypress/fixtures/FileInput/file.bar b/cypress/fixtures/FileInput/file.bar new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cypress/fixtures/FileInput/file.foo b/cypress/fixtures/FileInput/file.foo new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cypress/fixtures/FileInput/file.md b/cypress/fixtures/FileInput/file.md new file mode 100644 index 0000000000..9f748f2b81 --- /dev/null +++ b/cypress/fixtures/FileInput/file.md @@ -0,0 +1 @@ +# I'm a markdown file! diff --git a/cypress/fixtures/FileInput/file.txt b/cypress/fixtures/FileInput/file.txt new file mode 100644 index 0000000000..fba61dec4f --- /dev/null +++ b/cypress/fixtures/FileInput/file.txt @@ -0,0 +1 @@ +I'm a text file! diff --git a/cypress/fixtures/HeaderBar/dashboard.json b/cypress/fixtures/HeaderBar/dashboard.json new file mode 100644 index 0000000000..e208d7ad4f --- /dev/null +++ b/cypress/fixtures/HeaderBar/dashboard.json @@ -0,0 +1,4 @@ +{ + "unreadInterpretations": 10, + "unreadMessageConversations": 5 +} diff --git a/cypress/fixtures/HeaderBar/getModules.json b/cypress/fixtures/HeaderBar/getModules.json new file mode 100644 index 0000000000..2975f49350 --- /dev/null +++ b/cypress/fixtures/HeaderBar/getModules.json @@ -0,0 +1,84 @@ +{ + "modules": [ + { + "name": "dhis-web-dashboard", + "namespace": "/dhis-web-dashboard", + "defaultAction": "../dhis-web-dashboard/index.action", + "displayName": "Dashboard", + "icon": "../icons/dhis-web-dashboard.png", + "description": "" + }, + { + "name": "dhis-web-data-visualizer", + "namespace": "/dhis-web-data-visualizer", + "defaultAction": "../dhis-web-data-visualizer/index.action", + "displayName": "Data Visualizer", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "dhis-web-capture", + "namespace": "/dhis-web-capture", + "defaultAction": "../dhis-web-capture/index.action", + "displayName": "Capture", + "icon": "../icons/dhis-web-capture.png", + "description": "" + }, + { + "name": "dhis-web-maintenance", + "namespace": "/dhis-web-maintenance", + "defaultAction": "../dhis-web-maintenance/index.action", + "displayName": "Maintenance", + "icon": "../icons/dhis-web-maintenance.png", + "description": "" + }, + { + "name": "dhis-web-maps", + "namespace": "/dhis-web-maps", + "defaultAction": "../dhis-web-maps/index.action", + "displayName": "Maps", + "icon": "../icons/dhis-web-maps.png", + "description": "" + }, + { + "name": "dhis-web-event-reports", + "namespace": "/dhis-web-event-reports", + "defaultAction": "../dhis-web-event-reports/index.action", + "displayName": "Event Reports", + "icon": "../icons/dhis-web-event-reports.png", + "description": "" + }, + { + "name": "dhis-web-interpretation", + "namespace": "/dhis-web-interpretation", + "defaultAction": "../dhis-web-interpretation/index.action", + "displayName": "Interpretations", + "icon": "../icons/dhis-web-interpretation.png", + "description": "" + }, + { + "name": "dhis-web-importexport", + "namespace": "/dhis-web-importexport", + "defaultAction": "../dhis-web-importexport/index.action", + "displayName": "Import/Export", + "icon": "../icons/dhis-web-importexport.png", + "description": "" + }, + { + "name": "WHO Metadata browser", + "namespace": "WHO Metadata browser", + "defaultAction": "https://play.dhis2.org/2.32.0/api/apps/WHO-Metadata-browser/index.html", + "displayName": "", + "icon": "https://play.dhis2.org/2.32.0/api/apps/WHO-Metadata-browser/icons/medicine-48.png", + "description": "" + }, + { + "name": "Dashboard Classic", + "namespace": "Dashboard Classic", + "defaultAction": "https://play.dhis2.org/2.32.0/api/apps/Dashboard-Classic/index.html", + "displayName": "", + "icon": "https://play.dhis2.org/2.32.0/api/apps/Dashboard-Classic/icon.png", + "description": "DHIS2 Legacy Dashboard App" + } + ] +} diff --git a/cypress/fixtures/HeaderBar/logo_banner.json b/cypress/fixtures/HeaderBar/logo_banner.json new file mode 100644 index 0000000000..0a9411a9cc --- /dev/null +++ b/cypress/fixtures/HeaderBar/logo_banner.json @@ -0,0 +1,5 @@ +{ + "images": { + "png": "https://via.placeholder.com/150x50" + } +} diff --git a/cypress/fixtures/HeaderBar/me.json b/cypress/fixtures/HeaderBar/me.json new file mode 100644 index 0000000000..05d5631b7a --- /dev/null +++ b/cypress/fixtures/HeaderBar/me.json @@ -0,0 +1,7 @@ +{ + "name": "John Doe", + "email": "john_doe@dhis2.org", + "settings": { + "keyUiLocale": "en" + } +} diff --git a/cypress/fixtures/HeaderBar/systemInfo.json b/cypress/fixtures/HeaderBar/systemInfo.json new file mode 100644 index 0000000000..08e51bd481 --- /dev/null +++ b/cypress/fixtures/HeaderBar/systemInfo.json @@ -0,0 +1,4 @@ +{ + "systemName": "Foobar", + "contextPath": "https://play.dhis2.org/2.32.0" +} diff --git a/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000000.json b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000000.json new file mode 100644 index 0000000000..17002e6ec4 --- /dev/null +++ b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000000.json @@ -0,0 +1,21 @@ +{ + "path": "/A0000000000", + "displayName": "Org Unit 1", + "children": [ + { + "id": "A0000000001", + "displayName": "Org Unit 2", + "children": [{ "id": "A0000000003" }, { "id": "A0000000004" }] + }, + { + "id": "A0000000002", + "displayName": "Org Unit 3", + "children": [] + }, + { + "id": "A0000000006", + "displayName": "Org Unit 7", + "children": [] + } + ] +} diff --git a/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000001.json b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000001.json new file mode 100644 index 0000000000..8aacac714a --- /dev/null +++ b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000001.json @@ -0,0 +1,16 @@ +{ + "path": "/A0000000000/A0000000001", + "displayName": "Org Unit 2", + "children": [ + { + "id": "A0000000003", + "displayName": "Org Unit 4", + "children": [] + }, + { + "id": "A0000000004", + "displayName": "Org Unit 5", + "children": [] + } + ] +} diff --git a/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000002.json b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000002.json new file mode 100644 index 0000000000..cb3e6d0326 --- /dev/null +++ b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000002.json @@ -0,0 +1,5 @@ +{ + "path": "/A0000000000/A0000000002", + "displayName": "Org Unit 3", + "children": [] +} diff --git a/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000003.json b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000003.json new file mode 100644 index 0000000000..b0e8fee3bb --- /dev/null +++ b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000003.json @@ -0,0 +1,5 @@ +{ + "path": "/A0000000000/A0000000001/A0000000003", + "displayName": "Org Unit 4", + "children": [] +} diff --git a/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000004.json b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000004.json new file mode 100644 index 0000000000..827a039cba --- /dev/null +++ b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000004.json @@ -0,0 +1,5 @@ +{ + "path": "/A0000000000/A0000000001/A0000000004", + "displayName": "Org Unit 5", + "children": [] +} diff --git a/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000005.json b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000005.json new file mode 100644 index 0000000000..92318345ff --- /dev/null +++ b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000005.json @@ -0,0 +1,5 @@ +{ + "path": "/A0000000000/A0000000005", + "displayName": "Org Unit 6", + "children": [] +} diff --git a/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000006.json b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000006.json new file mode 100644 index 0000000000..1f83ccca47 --- /dev/null +++ b/cypress/fixtures/OrganisationUnitTree/organisationUnits/A0000000006.json @@ -0,0 +1,5 @@ +{ + "path": "/A0000000000/A0000000006", + "displayName": "Org Unit 7", + "children": [] +} diff --git a/cypress/integration/AlertBar/Accepts_hidden_cb.feature b/cypress/integration/AlertBar/Accepts_hidden_cb.feature new file mode 100644 index 0000000000..caa1b9b24c --- /dev/null +++ b/cypress/integration/AlertBar/Accepts_hidden_cb.feature @@ -0,0 +1,6 @@ +Feature: Calls onHidden cb when hidden + + Scenario: Alertbar will call the onHidden cb when hidden + Given an Alertbar with onHidden handler is rendered + When the Alertbar is hidden + Then the onHidden handler is called diff --git a/cypress/integration/AlertBar/Accepts_hidden_cb/index.js b/cypress/integration/AlertBar/Accepts_hidden_cb/index.js new file mode 100644 index 0000000000..a5e6c9b0b3 --- /dev/null +++ b/cypress/integration/AlertBar/Accepts_hidden_cb/index.js @@ -0,0 +1,18 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an Alertbar with onHidden handler is rendered', () => { + cy.visitStory('Alertbar', 'With onHidden') +}) + +When('the Alertbar is hidden', () => { + cy.wait(8000) + cy.get('[data-test="dhis2-uicore-alertbar"]').should('not.be.visible') +}) + +Then('the onHidden handler is called', () => { + cy.window().then(win => { + expect(win.onHidden).to.be.calledOnce + expect(win.onHidden).to.be.calledWith({}, null) + }) +}) diff --git a/cypress/integration/AlertBar/Accepts_icon.feature b/cypress/integration/AlertBar/Accepts_icon.feature new file mode 100644 index 0000000000..a4074ffed8 --- /dev/null +++ b/cypress/integration/AlertBar/Accepts_icon.feature @@ -0,0 +1,13 @@ +Feature: The AlertBars accepts an icon prop + + Scenario: AlertBar icon shows by default + Given a default AlertBar is rendered + Then the icon will be visible + + Scenario: AlertBar icon can be hidden + Given an AlertBar with disabled icon is rendered + Then the icon will not be visible + + Scenario: Custom AlertBar icon + Given an AlertBar with custom icon is rendered + Then the custom icon will be visible diff --git a/cypress/integration/AlertBar/Accepts_icon/index.js b/cypress/integration/AlertBar/Accepts_icon/index.js new file mode 100644 index 0000000000..4a89ffcf1b --- /dev/null +++ b/cypress/integration/AlertBar/Accepts_icon/index.js @@ -0,0 +1,24 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an AlertBar with disabled icon is rendered', () => { + cy.visitStory('AlertBar', 'Disabled icon') +}) + +Given('an AlertBar with custom icon is rendered', () => { + cy.visitStory('AlertBar', 'Custom icon') +}) + +Then('the icon will be visible', () => { + cy.get('[data-test="dhis2-uicore-alertbar-icon"]').should('be.visible') +}) + +Then('the icon will not be visible', () => { + cy.get('[data-test="dhis2-uicore-alertbar-icon"]').should('not.be.visible') +}) + +Then('the custom icon will be visible', () => { + cy.get('[data-test="dhis2-uicore-alertbar-icon"]') + .contains('Custom icon') + .should('be.visible') +}) diff --git a/cypress/integration/AlertBar/Accepts_message.feature b/cypress/integration/AlertBar/Accepts_message.feature new file mode 100644 index 0000000000..efa0d5330d --- /dev/null +++ b/cypress/integration/AlertBar/Accepts_message.feature @@ -0,0 +1,5 @@ +Feature: Shows a message + + Scenario: Standard AlertBar with a message + Given an AlertBar with a message is rendered + Then the message will be visible diff --git a/cypress/integration/AlertBar/Accepts_message/index.js b/cypress/integration/AlertBar/Accepts_message/index.js new file mode 100644 index 0000000000..ebdc7418b2 --- /dev/null +++ b/cypress/integration/AlertBar/Accepts_message/index.js @@ -0,0 +1,12 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an AlertBar with a message is rendered', () => { + cy.visitStory('AlertBar', 'With message') +}) + +Then('the message will be visible', () => { + cy.get('[data-test="dhis2-uicore-alertbar"]') + .contains('With a message') + .should('be.visible') +}) diff --git a/cypress/integration/AlertBar/Accepts_permanent.feature b/cypress/integration/AlertBar/Accepts_permanent.feature new file mode 100644 index 0000000000..0086225541 --- /dev/null +++ b/cypress/integration/AlertBar/Accepts_permanent.feature @@ -0,0 +1,6 @@ +Feature: Permanently displaying the AlertBar + + Scenario: The AlertBar renders with a permanent flag + Given an AlertBar with permanent is rendered + When the default duration has passed + Then the AlertBar will be visible diff --git a/cypress/integration/AlertBar/Accepts_permanent/index.js b/cypress/integration/AlertBar/Accepts_permanent/index.js new file mode 100644 index 0000000000..3861384080 --- /dev/null +++ b/cypress/integration/AlertBar/Accepts_permanent/index.js @@ -0,0 +1,11 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an AlertBar with permanent is rendered', () => { + cy.visitStory('AlertBar', 'Permanent') + cy.get('[data-test="dhis2-uicore-alertbar"]').should('be.visible') +}) + +Then('the AlertBar will be visible', () => { + cy.get('[data-test="dhis2-uicore-alertbar"]').should('be.visible') +}) diff --git a/cypress/integration/AlertBar/Hide_automatically.feature b/cypress/integration/AlertBar/Hide_automatically.feature new file mode 100644 index 0000000000..66ee4871ad --- /dev/null +++ b/cypress/integration/AlertBar/Hide_automatically.feature @@ -0,0 +1,11 @@ +Feature: The AlertBars will hide automatically + + Scenario: AlertBar hides automatically after the default time + Given a default AlertBar is rendered + When the default duration has passed + Then the AlertBar will not be visible + + Scenario: AlertBars hides automatically after a custom time + Given an AlertBar with a custom duration is rendered + When the custom duration has passed + Then the AlertBar will not be visible diff --git a/cypress/integration/AlertBar/Hide_automatically/index.js b/cypress/integration/AlertBar/Hide_automatically/index.js new file mode 100644 index 0000000000..cf71d2ee68 --- /dev/null +++ b/cypress/integration/AlertBar/Hide_automatically/index.js @@ -0,0 +1,15 @@ +import '../common/index' +import { Given, When } from 'cypress-cucumber-preprocessor/steps' + +Given('an AlertBar with a custom duration is rendered', () => { + cy.visitStory('AlertBar', 'Custom duration') + cy.get('[data-test="dhis2-uicore-alertbar"]').should('be.visible') +}) + +When('the custom duration has passed', () => { + cy.wait(2000) +}) + +When('the default duration has passed', () => { + cy.wait(8000) +}) diff --git a/cypress/integration/AlertBar/Hide_on_action.feature b/cypress/integration/AlertBar/Hide_on_action.feature new file mode 100644 index 0000000000..077f836922 --- /dev/null +++ b/cypress/integration/AlertBar/Hide_on_action.feature @@ -0,0 +1,6 @@ +Feature: The AlertBar will hide when the user clicks the Cancel button + + Scenario: The user clicks the "Cancel" button + Given a permanent AlertBar with actions is rendered + When the user clicks on the "Cancel" button + Then the AlertBar will not be visible diff --git a/cypress/integration/AlertBar/Hide_on_action/index.js b/cypress/integration/AlertBar/Hide_on_action/index.js new file mode 100644 index 0000000000..a61048b11e --- /dev/null +++ b/cypress/integration/AlertBar/Hide_on_action/index.js @@ -0,0 +1,13 @@ +import '../common/index' +import { Given, When } from 'cypress-cucumber-preprocessor/steps' + +Given('a permanent AlertBar with actions is rendered', () => { + cy.visitStory('AlertBar', 'Permanent with actions') + cy.get('[data-test="dhis2-uicore-alertbar"]').should('be.visible') +}) + +When('the user clicks on the "Cancel" button', () => { + cy.get( + '[data-test="dhis2-uicore-alertbar-action"]:contains("Cancel")' + ).click() +}) diff --git a/cypress/integration/AlertBar/common/index.js b/cypress/integration/AlertBar/common/index.js new file mode 100644 index 0000000000..ae9db80bb6 --- /dev/null +++ b/cypress/integration/AlertBar/common/index.js @@ -0,0 +1,14 @@ +import { When, Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default AlertBar is rendered', () => { + cy.visitStory('AlertBar', 'Default') + cy.get('[data-test="dhis2-uicore-alertbar"]').should('be.visible') +}) + +Then('the AlertBar will not be visible', () => { + cy.get('[data-test="dhis2-uicore-alertbar"]').should('not.be.visible') +}) + +When('the default duration has passed', () => { + cy.wait(8000) +}) diff --git a/cypress/integration/AlertStack/Shows_alertbars.feature b/cypress/integration/AlertStack/Shows_alertbars.feature new file mode 100644 index 0000000000..3da21828b7 --- /dev/null +++ b/cypress/integration/AlertStack/Shows_alertbars.feature @@ -0,0 +1,5 @@ +Feature: Shows AlertBars + + Scenario: AlertStack with AlertBars + Given an AlertStack with multiple AlertBars is rendered + Then the AlertBars will be visible diff --git a/cypress/integration/AlertStack/Shows_alertbars/index.js b/cypress/integration/AlertStack/Shows_alertbars/index.js new file mode 100644 index 0000000000..c02312f6da --- /dev/null +++ b/cypress/integration/AlertStack/Shows_alertbars/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an AlertStack with multiple AlertBars is rendered', () => { + cy.visitStory('Alertstack', 'Default') + cy.get('[data-test="dhis2-uicore-alertstack"]').should('be.visible') +}) + +Then('the AlertBars will be visible', () => { + cy.get('[data-test="dhis2-uicore-alertbar"]').should('have.length', 3) + cy.get('[data-test="dhis2-uicore-alertbar"]').should('be.visible') +}) diff --git a/cypress/integration/Backdrop/Accepts_children.feature b/cypress/integration/Backdrop/Accepts_children.feature new file mode 100644 index 0000000000..246f9e1a09 --- /dev/null +++ b/cypress/integration/Backdrop/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Backdrop renders children + + Scenario: A Backdrop with children + Given a Backdrop with children is rendered + Then the children are visible diff --git a/cypress/integration/Backdrop/Accepts_children/index.js b/cypress/integration/Backdrop/Accepts_children/index.js new file mode 100644 index 0000000000..93fee4d32e --- /dev/null +++ b/cypress/integration/Backdrop/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Backdrop with children is rendered', () => { + cy.visitStory('Backdrop', 'With children') + cy.get('[data-test="dhis2-uicore-backdrop"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Backdrop/Is_clickable.feature b/cypress/integration/Backdrop/Is_clickable.feature new file mode 100644 index 0000000000..19cac5a086 --- /dev/null +++ b/cypress/integration/Backdrop/Is_clickable.feature @@ -0,0 +1,6 @@ +Feature: The Backdrop has an onClick api + + Scenario: The user clicks on the Backdrop + Given a Backdrop with onClick handler is rendered + When the user clicks on the Backdrop + Then the onClick handler will be called diff --git a/cypress/integration/Backdrop/Is_clickable/index.js b/cypress/integration/Backdrop/Is_clickable/index.js new file mode 100644 index 0000000000..3b7c90406c --- /dev/null +++ b/cypress/integration/Backdrop/Is_clickable/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Backdrop with onClick handler is rendered', () => { + cy.visitStory('Backdrop', 'With onClick') +}) + +When('the user clicks on the Backdrop', () => { + cy.get('[data-test="dhis2-uicore-backdrop"]').click() +}) + +Then('the onClick handler will be called', () => { + cy.window().then(win => { + expect(win.onClick).to.be.calledWith({}) + }) +}) diff --git a/cypress/integration/Box/Accepts_children.feature b/cypress/integration/Box/Accepts_children.feature new file mode 100644 index 0000000000..ed14a95680 --- /dev/null +++ b/cypress/integration/Box/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Box renders children + + Scenario: A Box with children + Given a Box with children is rendered + Then the children are visible diff --git a/cypress/integration/Box/Accepts_children/index.js b/cypress/integration/Box/Accepts_children/index.js new file mode 100644 index 0000000000..6688a98ba2 --- /dev/null +++ b/cypress/integration/Box/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Box with children is rendered', () => { + cy.visitStory('Box', 'With children') + cy.get('[data-test="dhis2-uicore-box"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Button/Can_be_blurred.feature b/cypress/integration/Button/Can_be_blurred.feature new file mode 100644 index 0000000000..a962286b3c --- /dev/null +++ b/cypress/integration/Button/Can_be_blurred.feature @@ -0,0 +1,6 @@ +Feature: The Button has an onBlur api + + Scenario: The user blurs the Button + Given an Button with initialFocus and onBlur handler is rendered + When the Button is blurred + Then the onBlur handler is called diff --git a/cypress/integration/Button/Can_be_blurred/index.js b/cypress/integration/Button/Can_be_blurred/index.js new file mode 100644 index 0000000000..124deb0e90 --- /dev/null +++ b/cypress/integration/Button/Can_be_blurred/index.js @@ -0,0 +1,18 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an Button with initialFocus and onBlur handler is rendered', () => { + cy.visitStory('Button', 'With initialFocus and onBlur') +}) + +When('the Button is blurred', () => { + cy.get('[data-test="dhis2-uicore-button"]').blur() +}) + +Then('the onBlur handler is called', () => { + cy.window().then(win => { + expect(win.onBlur).to.be.calledWith({ + value: 'default', + name: 'Button', + }) + }) +}) diff --git a/cypress/integration/Button/Can_be_clicked.feature b/cypress/integration/Button/Can_be_clicked.feature new file mode 100644 index 0000000000..a47e4c7c54 --- /dev/null +++ b/cypress/integration/Button/Can_be_clicked.feature @@ -0,0 +1,6 @@ +Feature: The button has an onClick api + + Scenario: The user clicks on the Button + Given a Button with onClick handler is rendered + When the Button is clicked + Then the onClick handler is called diff --git a/cypress/integration/Button/Can_be_clicked/index.js b/cypress/integration/Button/Can_be_clicked/index.js new file mode 100644 index 0000000000..df1857b1d5 --- /dev/null +++ b/cypress/integration/Button/Can_be_clicked/index.js @@ -0,0 +1,18 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Button with onClick handler is rendered', () => { + cy.visitStory('Button', 'With onClick') +}) + +When('the Button is clicked', () => { + cy.get('[data-test="dhis2-uicore-button"]').click() +}) + +Then('the onClick handler is called', () => { + cy.window().then(win => { + expect(win.onClick).to.be.calledWith({ + name: 'Button', + value: 'default', + }) + }) +}) diff --git a/cypress/integration/Button/Can_be_focused.feature b/cypress/integration/Button/Can_be_focused.feature new file mode 100644 index 0000000000..c2aa69750c --- /dev/null +++ b/cypress/integration/Button/Can_be_focused.feature @@ -0,0 +1,6 @@ +Feature: The Button has an onFocus api + + Scenario: The user focuses the Button + Given a Button with onFocus handler is rendered + When the Button is focused + Then the onFocus handler is called diff --git a/cypress/integration/Button/Can_be_focused/index.js b/cypress/integration/Button/Can_be_focused/index.js new file mode 100644 index 0000000000..6613cc7121 --- /dev/null +++ b/cypress/integration/Button/Can_be_focused/index.js @@ -0,0 +1,18 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Button with onFocus handler is rendered', () => { + cy.visitStory('Button', 'With onFocus') +}) + +When('the Button is focused', () => { + cy.get('[data-test="dhis2-uicore-button"]').focus() +}) + +Then('the onFocus handler is called', () => { + cy.window().then(win => { + expect(win.onFocus).to.be.calledWith({ + value: 'default', + name: 'Button', + }) + }) +}) diff --git a/cypress/integration/ButtonStrip/Accepts_children.feature b/cypress/integration/ButtonStrip/Accepts_children.feature new file mode 100644 index 0000000000..80d9ffa6a5 --- /dev/null +++ b/cypress/integration/ButtonStrip/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The ButtonStrip renders children + + Scenario: A ButtonStrip with children + Given a ButtonStrip with children is rendered + Then the children are visible diff --git a/cypress/integration/ButtonStrip/Accepts_children/index.js b/cypress/integration/ButtonStrip/Accepts_children/index.js new file mode 100644 index 0000000000..f75dbe677b --- /dev/null +++ b/cypress/integration/ButtonStrip/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a ButtonStrip with children is rendered', () => { + cy.visitStory('ButtonStrip', 'With children') + cy.get('[data-test="dhis2-uicore-buttonstrip"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Card/Accepts_children.feature b/cypress/integration/Card/Accepts_children.feature new file mode 100644 index 0000000000..d3c50fdfe7 --- /dev/null +++ b/cypress/integration/Card/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Card renders children + + Scenario: A Card with children + Given a Card with children is rendered + Then the children are visible diff --git a/cypress/integration/Card/Accepts_children/index.js b/cypress/integration/Card/Accepts_children/index.js new file mode 100644 index 0000000000..0934ac278c --- /dev/null +++ b/cypress/integration/Card/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Card with children is rendered', () => { + cy.visitStory('Card', 'With children') + cy.get('[data-test="dhis2-uicore-card"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Checkbox/Accepts_initial_focus.feature b/cypress/integration/Checkbox/Accepts_initial_focus.feature new file mode 100644 index 0000000000..aa13f60d43 --- /dev/null +++ b/cypress/integration/Checkbox/Accepts_initial_focus.feature @@ -0,0 +1,5 @@ +Feature: Focusing the Checkbox on mount + + Scenario: The Checkbox renders with focus + Given a Checkbox with initialFocus is rendered + Then the Checkbox is focused diff --git a/cypress/integration/Checkbox/Accepts_initial_focus/index.js b/cypress/integration/Checkbox/Accepts_initial_focus/index.js new file mode 100644 index 0000000000..009ae527cb --- /dev/null +++ b/cypress/integration/Checkbox/Accepts_initial_focus/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Checkbox with initialFocus is rendered', () => { + cy.visitStory('Checkbox', 'With initialFocus') +}) + +Then('the Checkbox is focused', () => { + cy.focused() + .parent('[data-test="dhis2-uicore-checkbox"]') + .should('exist') +}) diff --git a/cypress/integration/Checkbox/Accepts_label.feature b/cypress/integration/Checkbox/Accepts_label.feature new file mode 100644 index 0000000000..4bb799be3c --- /dev/null +++ b/cypress/integration/Checkbox/Accepts_label.feature @@ -0,0 +1,5 @@ +Feature: The Checkbox shows a label + + Scenario: The Checkbox has a label + Given a Checkbox with a label is rendered + Then the label is shown diff --git a/cypress/integration/Checkbox/Accepts_label/index.js b/cypress/integration/Checkbox/Accepts_label/index.js new file mode 100644 index 0000000000..b1e165e691 --- /dev/null +++ b/cypress/integration/Checkbox/Accepts_label/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Checkbox with a label is rendered', () => { + cy.visitStory('Checkbox', 'With label') +}) + +Then('the label is shown', () => { + cy.get('[data-test="dhis2-uicore-checkbox"]') + .contains('The label') + .should('be.visible') +}) diff --git a/cypress/integration/Checkbox/Can_be_blurred.feature b/cypress/integration/Checkbox/Can_be_blurred.feature new file mode 100644 index 0000000000..4d74292009 --- /dev/null +++ b/cypress/integration/Checkbox/Can_be_blurred.feature @@ -0,0 +1,6 @@ +Feature: The Checkbox has an onBlur api + + Scenario: The user blurs the Checkbox + Given a Checkbox with initialFocus and onBlur handler is rendered + When the Checkbox is blurred + Then the onBlur handler is called diff --git a/cypress/integration/Checkbox/Can_be_blurred/index.js b/cypress/integration/Checkbox/Can_be_blurred/index.js new file mode 100644 index 0000000000..fe5bcb6e4d --- /dev/null +++ b/cypress/integration/Checkbox/Can_be_blurred/index.js @@ -0,0 +1,19 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Checkbox with initialFocus and onBlur handler is rendered', () => { + cy.visitStory('Checkbox', 'With initialFocus and onBlur') +}) + +When('the Checkbox is blurred', () => { + cy.get('[data-test="dhis2-uicore-checkbox"] input').blur() +}) + +Then('the onBlur handler is called', () => { + cy.window().then(win => { + expect(win.onBlur).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/Checkbox/Can_be_changed.feature b/cypress/integration/Checkbox/Can_be_changed.feature new file mode 100644 index 0000000000..029249c87f --- /dev/null +++ b/cypress/integration/Checkbox/Can_be_changed.feature @@ -0,0 +1,6 @@ +Feature: The Checkbox has an onChange api + + Scenario: The user clicks the Checkbox + Given a Checkbox with onChange handler is rendered + When the Checkbox is clicked + Then the onChange handler is called diff --git a/cypress/integration/Checkbox/Can_be_changed/index.js b/cypress/integration/Checkbox/Can_be_changed/index.js new file mode 100644 index 0000000000..10d8e00620 --- /dev/null +++ b/cypress/integration/Checkbox/Can_be_changed/index.js @@ -0,0 +1,19 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Checkbox with onChange handler is rendered', () => { + cy.visitStory('Checkbox', 'With onChange') +}) + +When('the Checkbox is clicked', () => { + cy.get('[data-test="dhis2-uicore-checkbox"]').click() +}) + +Then('the onChange handler is called', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/Checkbox/Can_be_disabled.feature b/cypress/integration/Checkbox/Can_be_disabled.feature new file mode 100644 index 0000000000..393f105175 --- /dev/null +++ b/cypress/integration/Checkbox/Can_be_disabled.feature @@ -0,0 +1,6 @@ +Feature: The Checkbox can be disabled + + Scenario: The user clicks a disabled Checkbox + Given a disabled Checkbox with onClick handler is rendered + When the Checkbox is clicked + Then the onClick handler is not called diff --git a/cypress/integration/Checkbox/Can_be_disabled/index.js b/cypress/integration/Checkbox/Can_be_disabled/index.js new file mode 100644 index 0000000000..fcfb01b658 --- /dev/null +++ b/cypress/integration/Checkbox/Can_be_disabled/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a disabled Checkbox with onClick handler is rendered', () => { + cy.visitStory('Checkbox', 'Disabled with onClick') +}) + +When('the Checkbox is clicked', () => { + cy.get('[data-test="dhis2-uicore-checkbox"] input').click({ force: true }) +}) + +Then('the onClick handler is not called', () => { + cy.window().then(win => { + expect(win.onClick).not.to.be.called + }) +}) diff --git a/cypress/integration/Checkbox/Can_be_focused.feature b/cypress/integration/Checkbox/Can_be_focused.feature new file mode 100644 index 0000000000..bca2923a93 --- /dev/null +++ b/cypress/integration/Checkbox/Can_be_focused.feature @@ -0,0 +1,6 @@ +Feature: The Checkbox has an onFocus api + + Scenario: The user focuses the Checkbox + Given a Checkbox with onFocus handler is rendered + When the Checkbox is focused + Then the onFocus handler is called diff --git a/cypress/integration/Checkbox/Can_be_focused/index.js b/cypress/integration/Checkbox/Can_be_focused/index.js new file mode 100644 index 0000000000..fe7b9aae77 --- /dev/null +++ b/cypress/integration/Checkbox/Can_be_focused/index.js @@ -0,0 +1,19 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Checkbox with onFocus handler is rendered', () => { + cy.visitStory('Checkbox', 'With onFocus') +}) + +When('the Checkbox is focused', () => { + cy.get('[data-test="dhis2-uicore-checkbox"] input').focus() +}) + +Then('the onFocus handler is called', () => { + cy.window().then(win => { + expect(win.onFocus).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/Checkbox/Has_indeterminate_prop.feature b/cypress/integration/Checkbox/Has_indeterminate_prop.feature new file mode 100644 index 0000000000..b37f3baafc --- /dev/null +++ b/cypress/integration/Checkbox/Has_indeterminate_prop.feature @@ -0,0 +1,9 @@ +Feature: The input component has an indeterminate prop + + Scenario: The checkbox has the indeterminate prop + Given the checkbox is marked as indeterminate + Then its input-element's indeterminate prop is true + + Scenario: The checkbox does not have the indeterminate prop + Given the checkbox is not marked as indeterminate + Then its input-element's indeterminate prop is false diff --git a/cypress/integration/Checkbox/Has_indeterminate_prop/index.js b/cypress/integration/Checkbox/Has_indeterminate_prop/index.js new file mode 100644 index 0000000000..093feb4dfe --- /dev/null +++ b/cypress/integration/Checkbox/Has_indeterminate_prop/index.js @@ -0,0 +1,19 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the checkbox is marked as indeterminate', () => { + cy.visitStory('Checkbox', 'Indeterminate prop') +}) + +Given('the checkbox is not marked as indeterminate', () => { + cy.visitStory('Checkbox', 'No indeterminate prop') +}) + +Then("its input-element's indeterminate prop is {word}", bool => { + cy.get('input').then($input => { + if (bool === 'true') { + expect($input[0].indeterminate).to.be.true + } else { + expect($input[0].indeterminate).to.be.false + } + }) +}) diff --git a/cypress/integration/CheckboxField/Accepts_help_text.feature b/cypress/integration/CheckboxField/Accepts_help_text.feature new file mode 100644 index 0000000000..f0a3374d78 --- /dev/null +++ b/cypress/integration/CheckboxField/Accepts_help_text.feature @@ -0,0 +1,5 @@ +Feature: Help text for the CheckboxField + + Scenario: Rendering a CheckboxField with help text + Given a CheckboxField with help text is rendered + Then the help text is visible diff --git a/cypress/integration/CheckboxField/Accepts_help_text/index.js b/cypress/integration/CheckboxField/Accepts_help_text/index.js new file mode 100644 index 0000000000..b803a895dc --- /dev/null +++ b/cypress/integration/CheckboxField/Accepts_help_text/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a CheckboxField with help text is rendered', () => { + cy.visitStory('CheckboxField', 'With help text') +}) + +Then('the help text is visible', () => { + cy.get('[data-test="dhis2-uicore-checkboxfield-help"]') + .contains('Help text') + .should('be.visible') +}) diff --git a/cypress/integration/CheckboxField/Accepts_label.feature b/cypress/integration/CheckboxField/Accepts_label.feature new file mode 100644 index 0000000000..b80629ae92 --- /dev/null +++ b/cypress/integration/CheckboxField/Accepts_label.feature @@ -0,0 +1,5 @@ +Feature: Label for the CheckboxField + + Scenario: Rendering a CheckboxField with a label + Given a CheckboxField with a label is rendered + Then the label is visible diff --git a/cypress/integration/CheckboxField/Accepts_label/index.js b/cypress/integration/CheckboxField/Accepts_label/index.js new file mode 100644 index 0000000000..6c7950a109 --- /dev/null +++ b/cypress/integration/CheckboxField/Accepts_label/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a CheckboxField with a label is rendered', () => { + cy.visitStory('CheckboxField', 'With label') +}) + +Then('the label is visible', () => { + cy.get('[data-test="dhis2-uicore-checkboxfield"]') + .contains('The label') + .should('be.visible') +}) diff --git a/cypress/integration/CheckboxField/Accepts_validation_text.feature b/cypress/integration/CheckboxField/Accepts_validation_text.feature new file mode 100644 index 0000000000..912ac3a0b2 --- /dev/null +++ b/cypress/integration/CheckboxField/Accepts_validation_text.feature @@ -0,0 +1,5 @@ +Feature: Validation text for the CheckboxField + + Scenario: Rendering a CheckboxField with validation text + Given a CheckboxField with validation text is rendered + Then the validation text is visible diff --git a/cypress/integration/CheckboxField/Accepts_validation_text/index.js b/cypress/integration/CheckboxField/Accepts_validation_text/index.js new file mode 100644 index 0000000000..c9f58769a7 --- /dev/null +++ b/cypress/integration/CheckboxField/Accepts_validation_text/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a CheckboxField with validation text is rendered', () => { + cy.visitStory('CheckboxField', 'With validation text') +}) + +Then('the validation text is visible', () => { + cy.get('[data-test="dhis2-uicore-checkboxfield-validation"]') + .contains('Validation text') + .should('be.visible') +}) diff --git a/cypress/integration/CheckboxField/Can_be_required.feature b/cypress/integration/CheckboxField/Can_be_required.feature new file mode 100644 index 0000000000..6c8b6e3134 --- /dev/null +++ b/cypress/integration/CheckboxField/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the CheckboxField + + Scenario: Rendering a CheckboxField that is required + Given a CheckboxField with label and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/CheckboxField/Can_be_required/index.js b/cypress/integration/CheckboxField/Can_be_required/index.js new file mode 100644 index 0000000000..0ef97e14ec --- /dev/null +++ b/cypress/integration/CheckboxField/Can_be_required/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a CheckboxField with label and a required flag is rendered', () => { + cy.visitStory('CheckboxField', 'With label and required') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-checkboxfield-required"]').should( + 'be.visible' + ) +}) diff --git a/cypress/integration/Chip/Accepts_children.feature b/cypress/integration/Chip/Accepts_children.feature new file mode 100644 index 0000000000..fad69e4e41 --- /dev/null +++ b/cypress/integration/Chip/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Chip renders children + + Scenario: A Chip with children + Given a Chip with children is rendered + Then the children are visible diff --git a/cypress/integration/Chip/Accepts_children/index.js b/cypress/integration/Chip/Accepts_children/index.js new file mode 100644 index 0000000000..bccb08d786 --- /dev/null +++ b/cypress/integration/Chip/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Chip with children is rendered', () => { + cy.visitStory('Chip', 'With children') + cy.get('[data-test="dhis2-uicore-chip"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Chip/Accepts_icon.feature b/cypress/integration/Chip/Accepts_icon.feature new file mode 100644 index 0000000000..223453d28f --- /dev/null +++ b/cypress/integration/Chip/Accepts_icon.feature @@ -0,0 +1,9 @@ +Feature: The Chip accepts an icon prop + + Scenario: Default Chip does not render an icon + Given a default Chip is rendered + Then the icon will not be visible + + Scenario: Chip renders supplied icon + Given a Chip supplied with an icon is rendered + Then the icon will be visible diff --git a/cypress/integration/Chip/Accepts_icon/index.js b/cypress/integration/Chip/Accepts_icon/index.js new file mode 100644 index 0000000000..18e048b9d3 --- /dev/null +++ b/cypress/integration/Chip/Accepts_icon/index.js @@ -0,0 +1,21 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default Chip is rendered', () => { + cy.visitStory('Chip', 'Default') + cy.get('[data-test="dhis2-uicore-chip"]').should('be.visible') +}) + +Given('a Chip supplied with an icon is rendered', () => { + cy.visitStory('Chip', 'With icon') + cy.get('[data-test="dhis2-uicore-chip"]').should('be.visible') +}) + +Then('the icon will not be visible', () => { + cy.get('[data-test="dhis2-uicore-chip-icon"]').should('not.be.visible') +}) + +Then('the icon will be visible', () => { + cy.get('[data-test="dhis2-uicore-chip-icon"]') + .contains('Icon') + .should('be.visible') +}) diff --git a/cypress/integration/Chip/Is_clickable.feature b/cypress/integration/Chip/Is_clickable.feature new file mode 100644 index 0000000000..d4cf83fe32 --- /dev/null +++ b/cypress/integration/Chip/Is_clickable.feature @@ -0,0 +1,6 @@ +Feature: The Chip has an onClick api + + Scenario: The user clicks on the Chip + Given a Chip with onClick handler is rendered + When the Chip is clicked + Then the onClick handler is called diff --git a/cypress/integration/Chip/Is_clickable/index.js b/cypress/integration/Chip/Is_clickable/index.js new file mode 100644 index 0000000000..aa10d955c7 --- /dev/null +++ b/cypress/integration/Chip/Is_clickable/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Chip with onClick handler is rendered', () => { + cy.visitStory('Chip', 'With onClick') +}) + +When('the Chip is clicked', () => { + cy.get('[data-test="dhis2-uicore-chip"]').click() +}) + +Then('the onClick handler is called', () => { + cy.window().then(win => { + expect(win.onClick).to.be.calledWith({}) + }) +}) diff --git a/cypress/integration/Chip/Is_removable.feature b/cypress/integration/Chip/Is_removable.feature new file mode 100644 index 0000000000..3306387b00 --- /dev/null +++ b/cypress/integration/Chip/Is_removable.feature @@ -0,0 +1,6 @@ +Feature: The Chip has an onRemove api + + Scenario: The user removes the Chip + Given a Chip with onRemove handler is rendered + When the remove icon is clicked + Then the onRemove handler is called diff --git a/cypress/integration/Chip/Is_removable/index.js b/cypress/integration/Chip/Is_removable/index.js new file mode 100644 index 0000000000..cd35771f7c --- /dev/null +++ b/cypress/integration/Chip/Is_removable/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Chip with onRemove handler is rendered', () => { + cy.visitStory('Chip', 'With onRemove') +}) + +When('the remove icon is clicked', () => { + cy.get('[data-test="dhis2-uicore-chip-remove"]').click() +}) + +Then('the onRemove handler is called', () => { + cy.window().then(win => { + expect(win.onRemove).to.be.calledWith({}) + }) +}) diff --git a/cypress/integration/ComponentCover/Accepts_children.feature b/cypress/integration/ComponentCover/Accepts_children.feature new file mode 100644 index 0000000000..dc3ed9e197 --- /dev/null +++ b/cypress/integration/ComponentCover/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The ComponentCover renders children + + Scenario: A ComponentCover with children + Given a ComponentCover with children is rendered + Then the children are visible diff --git a/cypress/integration/ComponentCover/Accepts_children/index.js b/cypress/integration/ComponentCover/Accepts_children/index.js new file mode 100644 index 0000000000..49754b4375 --- /dev/null +++ b/cypress/integration/ComponentCover/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a ComponentCover with children is rendered', () => { + cy.visitStory('ComponentCover', 'With children') + cy.get('[data-test="dhis2-uicore-componentcover"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/CssVariables/Sets_variables.feature b/cypress/integration/CssVariables/Sets_variables.feature new file mode 100644 index 0000000000..4647dbaf69 --- /dev/null +++ b/cypress/integration/CssVariables/Sets_variables.feature @@ -0,0 +1,21 @@ +Feature: CssVariables sets css variables + + Scenario: CssVariables with colors + Given a CssVariables component with colors flag is rendered + Then the colors css variables are set + + Scenario: CssVariables with theme + Given a CssVariables component with theme flag is rendered + Then the theme css variables are set + + Scenario: CssVariables with layers + Given a CssVariables component with layers flag is rendered + Then the layers css variables are set + + Scenario: CssVariables with spacers + Given a CssVariables component with spacers flag is rendered + Then the spacers css variables are set + + Scenario: CssVariables with elevations + Given a CssVariables component with elevations flag is rendered + Then the elevations css variables are set diff --git a/cypress/integration/CssVariables/Sets_variables/index.js b/cypress/integration/CssVariables/Sets_variables/index.js new file mode 100644 index 0000000000..176deddfa2 --- /dev/null +++ b/cypress/integration/CssVariables/Sets_variables/index.js @@ -0,0 +1,53 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a CssVariables component with colors flag is rendered', () => { + cy.visitStory('CssVariables', 'With colors') +}) + +Given('a CssVariables component with theme flag is rendered', () => { + cy.visitStory('CssVariables', 'With theme') +}) + +Given('a CssVariables component with layers flag is rendered', () => { + cy.visitStory('CssVariables', 'With layers') +}) + +Given('a CssVariables component with spacers flag is rendered', () => { + cy.visitStory('CssVariables', 'With spacers') +}) + +Given('a CssVariables component with elevations flag is rendered', () => { + cy.visitStory('CssVariables', 'With elevations') +}) + +Then('the colors css variables are set', () => { + cy.get('div#custom').should( + 'have.css', + 'background-color', + 'rgb(9, 51, 113)' + ) +}) + +Then('the theme css variables are set', () => { + cy.get('div#custom').should( + 'have.css', + 'background-color', + 'rgb(9, 51, 113)' + ) +}) + +Then('the layers css variables are set', () => { + cy.get('div#custom').should('have.css', 'z-index', '9999') +}) + +Then('the spacers css variables are set', () => { + cy.get('div#custom').should('have.css', 'margin', '4px') +}) + +Then('the elevations css variables are set', () => { + cy.get('div#custom').should( + 'have.css', + 'box-shadow', + 'rgba(64, 75, 90, 0.2) 0px 0px 1px 0px, rgba(64, 75, 90, 0.28) 0px 2px 1px 0px' + ) +}) diff --git a/cypress/integration/DropdownButton/Accepts_children.feature b/cypress/integration/DropdownButton/Accepts_children.feature new file mode 100644 index 0000000000..16a9352ad3 --- /dev/null +++ b/cypress/integration/DropdownButton/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The DropdownButton renders children + + Scenario: A DropdownButton with children + Given a DropdownButton with children is rendered + Then the children are visible diff --git a/cypress/integration/DropdownButton/Accepts_children/index.js b/cypress/integration/DropdownButton/Accepts_children/index.js new file mode 100644 index 0000000000..3a989f1f2a --- /dev/null +++ b/cypress/integration/DropdownButton/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a DropdownButton with children is rendered', () => { + cy.visitStory('DropdownButton', 'With children') + cy.get('[data-test="dhis2-uicore-dropdownbutton"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/DropdownButton/Accepts_component.feature b/cypress/integration/DropdownButton/Accepts_component.feature new file mode 100644 index 0000000000..4d4b1a5503 --- /dev/null +++ b/cypress/integration/DropdownButton/Accepts_component.feature @@ -0,0 +1,5 @@ +Feature: The DropdownButton accepts a component prop + + Scenario: A DropdownButton with a component + Given a DropdownButton with a component prop and opened dropdown is rendered + Then the component is visible diff --git a/cypress/integration/DropdownButton/Accepts_component/index.js b/cypress/integration/DropdownButton/Accepts_component/index.js new file mode 100644 index 0000000000..f60bac27f4 --- /dev/null +++ b/cypress/integration/DropdownButton/Accepts_component/index.js @@ -0,0 +1,18 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a DropdownButton with a component prop and opened dropdown is rendered', + () => { + cy.visitStory('DropdownButton', 'With component') + + cy.get('[data-test="dhis2-uicore-dropdownbutton"]').should('be.visible') + cy.get('[data-test="dhis2-uicore-dropdownbutton"]').click() + cy.get('[data-test="dhis2-uicore-dropdownbutton-popper"]').should( + 'exist' + ) + } +) + +Then('the component is visible', () => { + cy.contains('I am a component').should('be.visible') +}) diff --git a/cypress/integration/DropdownButton/Accepts_icon.feature b/cypress/integration/DropdownButton/Accepts_icon.feature new file mode 100644 index 0000000000..8a5e45e9ab --- /dev/null +++ b/cypress/integration/DropdownButton/Accepts_icon.feature @@ -0,0 +1,5 @@ +Feature: The DropdownButton accepts an icon prop + + Scenario: A DropdownButton with an icon + Given a DropdownButton with an icon prop is rendered + Then the icon is visible diff --git a/cypress/integration/DropdownButton/Accepts_icon/index.js b/cypress/integration/DropdownButton/Accepts_icon/index.js new file mode 100644 index 0000000000..3cef147fc3 --- /dev/null +++ b/cypress/integration/DropdownButton/Accepts_icon/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a DropdownButton with an icon prop is rendered', () => { + cy.visitStory('DropdownButton', 'With icon') + cy.get('[data-test="dhis2-uicore-dropdownbutton"]').should('be.visible') +}) + +Then('the icon is visible', () => { + cy.contains('Icon').should('be.visible') +}) diff --git a/cypress/integration/DropdownButton/Accepts_initial_focus.feature b/cypress/integration/DropdownButton/Accepts_initial_focus.feature new file mode 100644 index 0000000000..3ca3fba87b --- /dev/null +++ b/cypress/integration/DropdownButton/Accepts_initial_focus.feature @@ -0,0 +1,5 @@ +Feature: Focusing the DropdownButton on mount + + Scenario: The DropdownButton renders with focus + Given a DropdownButton with initialFocus is rendered + Then the DropdownButton is focused diff --git a/cypress/integration/DropdownButton/Accepts_initial_focus/index.js b/cypress/integration/DropdownButton/Accepts_initial_focus/index.js new file mode 100644 index 0000000000..d7fb3e4a1e --- /dev/null +++ b/cypress/integration/DropdownButton/Accepts_initial_focus/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a DropdownButton with initialFocus is rendered', () => { + cy.visitStory('DropdownButton', 'With initialFocus') +}) + +Then('the DropdownButton is focused', () => { + cy.focused() + .parent('[data-test="dhis2-uicore-dropdownbutton"]') + .should('exist') +}) diff --git a/cypress/integration/DropdownButton/Button_is_clickable.feature b/cypress/integration/DropdownButton/Button_is_clickable.feature new file mode 100644 index 0000000000..6f137e21b6 --- /dev/null +++ b/cypress/integration/DropdownButton/Button_is_clickable.feature @@ -0,0 +1,6 @@ +Feature: The DropdownButton has an onClick api + + Scenario: The user clicks on the DropdownButton + Given a DropdownButton with onClick handler is rendered + When the DropdownButton is clicked + Then the onClick handler is called diff --git a/cypress/integration/DropdownButton/Button_is_clickable/index.js b/cypress/integration/DropdownButton/Button_is_clickable/index.js new file mode 100644 index 0000000000..fbf89a4da4 --- /dev/null +++ b/cypress/integration/DropdownButton/Button_is_clickable/index.js @@ -0,0 +1,16 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a DropdownButton with onClick handler is rendered', () => { + cy.visitStory('DropdownButton', 'With onClick') +}) + +Then('the onClick handler is called', () => { + cy.window().then(win => { + expect(win.onClick).to.be.calledWith({ + name: 'Button', + value: 'default', + open: true, + }) + }) +}) diff --git a/cypress/integration/DropdownButton/Can_be_disabled.feature b/cypress/integration/DropdownButton/Can_be_disabled.feature new file mode 100644 index 0000000000..20632fc095 --- /dev/null +++ b/cypress/integration/DropdownButton/Can_be_disabled.feature @@ -0,0 +1,6 @@ +Feature: The DropdownButton can be disabled + + Scenario: The user clicks a disabled DropdownButton + Given a disabled DropdownButton with onClick handler is rendered + When the DropdownButton is clicked + Then the onClick handler is not called diff --git a/cypress/integration/DropdownButton/Can_be_disabled/index.js b/cypress/integration/DropdownButton/Can_be_disabled/index.js new file mode 100644 index 0000000000..c6adeebcb6 --- /dev/null +++ b/cypress/integration/DropdownButton/Can_be_disabled/index.js @@ -0,0 +1,17 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a disabled DropdownButton with onClick handler is rendered', () => { + cy.visitStory('DropdownButton', 'Disabled with onClick') +}) + +When('the DropdownButton is clicked', () => { + cy.get('[data-test="dhis2-uicore-dropdownbutton"] button').click({ + force: true, + }) +}) + +Then('the onClick handler is not called', () => { + cy.window().then(win => { + expect(win.onClick).not.to.be.called + }) +}) diff --git a/cypress/integration/DropdownButton/Opens_a_dropdown.feature b/cypress/integration/DropdownButton/Opens_a_dropdown.feature new file mode 100644 index 0000000000..6d1224ec5b --- /dev/null +++ b/cypress/integration/DropdownButton/Opens_a_dropdown.feature @@ -0,0 +1,11 @@ +Feature: The DropdownButton renders a dropdown + + Scenario: The user opens the dropdown + Given a default DropdownButton is rendered + When the DropdownButton is clicked + Then the dropdown is rendered + + Scenario: The user closes the dropdown + Given a DropdownButton with opened dropdown is rendered + When the Backdrop is clicked + Then the dropdown is not rendered diff --git a/cypress/integration/DropdownButton/Opens_a_dropdown/index.js b/cypress/integration/DropdownButton/Opens_a_dropdown/index.js new file mode 100644 index 0000000000..afe2881c6c --- /dev/null +++ b/cypress/integration/DropdownButton/Opens_a_dropdown/index.js @@ -0,0 +1,27 @@ +import '../common/index' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default DropdownButton is rendered', () => { + cy.visitStory('DropdownButton', 'Default') +}) + +Given('a DropdownButton with opened dropdown is rendered', () => { + cy.visitStory('DropdownButton', 'Default') + + cy.get('[data-test="dhis2-uicore-dropdownbutton"]').click() + cy.get('[data-test="dhis2-uicore-dropdownbutton-popper"]').should('exist') +}) + +When('the Backdrop is clicked', () => { + cy.get('[data-test="dhis2-uicore-backdrop"]').click() +}) + +Then('the dropdown is not rendered', () => { + cy.get('[data-test="dhis2-uicore-dropdownbutton-popper"]').should( + 'not.exist' + ) +}) + +Then('the dropdown is rendered', () => { + cy.get('[data-test="dhis2-uicore-dropdownbutton-popper"]').should('exist') +}) diff --git a/cypress/integration/DropdownButton/common/index.js b/cypress/integration/DropdownButton/common/index.js new file mode 100644 index 0000000000..96dcec6ddc --- /dev/null +++ b/cypress/integration/DropdownButton/common/index.js @@ -0,0 +1,5 @@ +import { When } from 'cypress-cucumber-preprocessor/steps' + +When('the DropdownButton is clicked', () => { + cy.get('[data-test="dhis2-uicore-dropdownbutton"]').click() +}) diff --git a/cypress/integration/Field/Accepts_children.feature b/cypress/integration/Field/Accepts_children.feature new file mode 100644 index 0000000000..c1432d07f2 --- /dev/null +++ b/cypress/integration/Field/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Field renders children + + Scenario: A Field with children + Given a Field with children is rendered + Then the children are visible diff --git a/cypress/integration/Field/Accepts_children/index.js b/cypress/integration/Field/Accepts_children/index.js new file mode 100644 index 0000000000..4bbdc3d507 --- /dev/null +++ b/cypress/integration/Field/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Field with children is rendered', () => { + cy.visitStory('Field', 'With children') + cy.get('[data-test="dhis2-uicore-field"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/FieldSet/Accepts_children.feature b/cypress/integration/FieldSet/Accepts_children.feature new file mode 100644 index 0000000000..6742bbaf95 --- /dev/null +++ b/cypress/integration/FieldSet/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The FieldSet renders children + + Scenario: A FieldSet with children + Given a FieldSet with children is rendered + Then the children are visible diff --git a/cypress/integration/FieldSet/Accepts_children/index.js b/cypress/integration/FieldSet/Accepts_children/index.js new file mode 100644 index 0000000000..5322f4ead4 --- /dev/null +++ b/cypress/integration/FieldSet/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FieldSet with children is rendered', () => { + cy.visitStory('FieldSet', 'With children') + cy.get('[data-test="dhis2-uicore-fieldset"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/FileInput/Accepts_multiple_files.feature b/cypress/integration/FileInput/Accepts_multiple_files.feature new file mode 100644 index 0000000000..f193970bb9 --- /dev/null +++ b/cypress/integration/FileInput/Accepts_multiple_files.feature @@ -0,0 +1,8 @@ +Feature: The FileInput can handle multiple files + + Scenario: The user selects multiple files + Given a FileInput with multiple and onChange handler is rendered + And the FileInput does not have any files + When the user selected multiple files + Then the onChange handler is called + Then the onChange handler's payload contains multiple files diff --git a/cypress/integration/FileInput/Accepts_multiple_files/index.js b/cypress/integration/FileInput/Accepts_multiple_files/index.js new file mode 100644 index 0000000000..da24fbf785 --- /dev/null +++ b/cypress/integration/FileInput/Accepts_multiple_files/index.js @@ -0,0 +1,29 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileInput with multiple and onChange handler is rendered', () => { + cy.visitStory('FileInput', 'With onChange and multiple') +}) + +When('the user selected multiple files', () => { + cy.get('[data-test="dhis2-uicore-fileinput"] input').uploadMultipleFiles( + [ + { fileType: 'md', fixture: 'FileInput/file.md' }, + { fileType: 'txt', fixture: 'FileInput/file.txt' }, + ], + true + ) +}) + +Then("the onChange handler's payload contains multiple files", () => { + cy.get('@payload').then(payload => { + const files = payload.files + expect(files).to.have.lengthOf(2) + + const file1 = files[0] + expect(file1.name).to.equal('file.md') + + const file2 = files[1] + expect(file2.name).to.equal('file.txt') + }) +}) diff --git a/cypress/integration/FileInput/Can_be_blurred.feature b/cypress/integration/FileInput/Can_be_blurred.feature new file mode 100644 index 0000000000..5653c0013b --- /dev/null +++ b/cypress/integration/FileInput/Can_be_blurred.feature @@ -0,0 +1,6 @@ +Feature: The FileInput has an onBlur api + + Scenario: The user blurs the FileInput + Given an FileInput with initialFocus and onBlur handler is rendered + When the FileInput is blurred + Then the onBlur handler is called diff --git a/cypress/integration/FileInput/Can_be_blurred/index.js b/cypress/integration/FileInput/Can_be_blurred/index.js new file mode 100644 index 0000000000..02bd4290dd --- /dev/null +++ b/cypress/integration/FileInput/Can_be_blurred/index.js @@ -0,0 +1,20 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an FileInput with initialFocus and onBlur handler is rendered', () => { + cy.visitStory('FileInput', 'With initialFocus and onBlur') +}) + +When('the FileInput is blurred', () => { + cy.get('[data-test="dhis2-uicore-fileinput"] button').blur() +}) + +Then('the onBlur handler is called', () => { + cy.window().then(win => { + cy.get('[data-test="dhis2-uicore-fileinput"] input').then(fileInput => { + expect(win.onBlur).to.be.calledWith({ + name: 'upload', + files: fileInput[0].files, + }) + }) + }) +}) diff --git a/cypress/integration/FileInput/Can_be_changed.feature b/cypress/integration/FileInput/Can_be_changed.feature new file mode 100644 index 0000000000..66eba25dd9 --- /dev/null +++ b/cypress/integration/FileInput/Can_be_changed.feature @@ -0,0 +1,8 @@ +Feature: The FIleInput has an onChange api + + Scenario: The user selects a file + Given a FileInput with onChange handler is rendered + And the FileInput does not have any files + When a file is selected + Then the onChange handler is called + Then the onChange handler's payload contains the file diff --git a/cypress/integration/FileInput/Can_be_changed/index.js b/cypress/integration/FileInput/Can_be_changed/index.js new file mode 100644 index 0000000000..0a99709de4 --- /dev/null +++ b/cypress/integration/FileInput/Can_be_changed/index.js @@ -0,0 +1,23 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileInput with onChange handler is rendered', () => { + cy.visitStory('FileInput', 'With onChange') +}) + +When('a file is selected', () => { + cy.get('[data-test="dhis2-uicore-fileinput"] input').uploadSingleFile( + '.md', + 'FileInput/file.md' + ) +}) + +Then("the onChange handler's payload contains the file", () => { + cy.get('@payload').then(payload => { + const files = payload.files + expect(files).to.have.lengthOf(1) + + const file1 = files[0] + expect(file1.name).to.equal('file.md') + }) +}) diff --git a/cypress/integration/FileInput/Can_be_focused.feature b/cypress/integration/FileInput/Can_be_focused.feature new file mode 100644 index 0000000000..a9e12ad7ab --- /dev/null +++ b/cypress/integration/FileInput/Can_be_focused.feature @@ -0,0 +1,6 @@ +Feature: The FileInput has an onFocus api + + Scenario: The user focuses the FileInput + Given a FileInput with onFocus handler is rendered + When the FileInput is focused + Then the onFocus handler is called diff --git a/cypress/integration/FileInput/Can_be_focused/index.js b/cypress/integration/FileInput/Can_be_focused/index.js new file mode 100644 index 0000000000..f4df12d8d6 --- /dev/null +++ b/cypress/integration/FileInput/Can_be_focused/index.js @@ -0,0 +1,20 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileInput with onFocus handler is rendered', () => { + cy.visitStory('FileInput', 'With onFocus') +}) + +When('the FileInput is focused', () => { + cy.get('[data-test="dhis2-uicore-fileinput"] button').focus() +}) + +Then('the onFocus handler is called', () => { + cy.window().then(win => { + cy.get('[data-test="dhis2-uicore-fileinput"] input').then(fileInput => { + expect(win.onFocus).to.be.calledWith({ + name: 'upload', + files: fileInput[0].files, + }) + }) + }) +}) diff --git a/cypress/integration/FileInput/common/index.js b/cypress/integration/FileInput/common/index.js new file mode 100644 index 0000000000..0940c7dd30 --- /dev/null +++ b/cypress/integration/FileInput/common/index.js @@ -0,0 +1,23 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the FileInput does not have any files', () => { + cy.get('[data-test="dhis2-uicore-fileinput"] input').then($input => { + const files = $input[0].files + expect(files).to.have.lengthOf(0) + }) +}) + +Then('the onChange handler is called', () => { + cy.window().then(win => { + const calls = win.onChange.getCalls() + expect(calls).to.have.lengthOf(1) + + const callArgs = calls[0].args + expect(callArgs).to.have.lengthOf(2) + + const payload = callArgs[0] + expect(Object.keys(payload)).to.include.members(['files', 'name']) + + cy.wrap(payload).as('payload') + }) +}) diff --git a/cypress/integration/FileInputField/Can_be_required.feature b/cypress/integration/FileInputField/Can_be_required.feature new file mode 100644 index 0000000000..e85637d743 --- /dev/null +++ b/cypress/integration/FileInputField/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the FileInputField + + Scenario: Rendering a FileInputField that is required + Given a FileInputField with label and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/FileInputField/Can_be_required/index.js b/cypress/integration/FileInputField/Can_be_required/index.js new file mode 100644 index 0000000000..f8f1347708 --- /dev/null +++ b/cypress/integration/FileInputField/Can_be_required/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileInputField with label and a required flag is rendered', () => { + cy.visitStory('FileInputField', 'With label and required') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-label-required"]').should('be.visible') +}) diff --git a/cypress/integration/FileInputFieldWithList/Deduplicates_the_file_list.feature b/cypress/integration/FileInputFieldWithList/Deduplicates_the_file_list.feature new file mode 100644 index 0000000000..547ef948cb --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/Deduplicates_the_file_list.feature @@ -0,0 +1,8 @@ +Feature: The FileInputFieldWithList deduplicates the file list + + Scenario: The file list is deduplicated + Given a FileInputFieldWithList with multiple files is rendered + And the list contains the file duplicate.md + When the file duplicate.md is selected + Then the onChange handler is called + Then the onChange handler's payload contains a single entry for file.md \ No newline at end of file diff --git a/cypress/integration/FileInputFieldWithList/Deduplicates_the_file_list/index.js b/cypress/integration/FileInputFieldWithList/Deduplicates_the_file_list/index.js new file mode 100644 index 0000000000..acae7a915d --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/Deduplicates_the_file_list/index.js @@ -0,0 +1,38 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the list contains the file duplicate.md', () => { + cy.contains('duplicate.md') + .should('have.lengthOf', 1) + .should('have.class', 'label') + .closest('[data-test="dhis2-uicore-filelistitem"]') + .should('have.lengthOf', 1) +}) + +When('the file duplicate.md is selected', () => { + cy.window().then(win => { + cy.get('[data-test="dhis2-uicore-fileinput"] input').then($input => { + // name, lastModified, size and type are equal to the file created in the story + const duplicate = new File(...win.duplicateFileConstructorArgs) + const dataTransfer = new DataTransfer() + dataTransfer.items.add(duplicate) + $input[0].files = dataTransfer.files + cy.get($input).trigger('change', { force: true }) + }) + }) +}) + +Then( + "the onChange handler's payload contains a single entry for file.md", + () => { + cy.get('@payload').then(payload => { + const files = payload.files + expect(files).to.have.lengthOf(3) + + const filesWithNameFileMd = files.filter( + f => f.name === 'duplicate.md' + ) + expect(filesWithNameFileMd).to.have.lengthOf(1) + }) + } +) diff --git a/cypress/integration/FileInputFieldWithList/Disables_button_when_full.feature b/cypress/integration/FileInputFieldWithList/Disables_button_when_full.feature new file mode 100644 index 0000000000..18f92599f4 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/Disables_button_when_full.feature @@ -0,0 +1,6 @@ +Feature: The FileInputFieldWithList disables its button when full + + Scenario: The button is disabled when the fileInput is full + Given a FileInputFieldWithList without multiple is rendered + And the FileInputFieldWithList holds a file + Then the button is disabled diff --git a/cypress/integration/FileInputFieldWithList/Disables_button_when_full/index.js b/cypress/integration/FileInputFieldWithList/Disables_button_when_full/index.js new file mode 100644 index 0000000000..0327b9b3e4 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/Disables_button_when_full/index.js @@ -0,0 +1,13 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileInputFieldWithList without multiple is rendered', () => { + cy.visitStory('FileInputFieldWithList', 'Single file - with file') +}) + +Given('the FileInputFieldWithList holds a file', () => { + cy.get('[data-test="dhis2-uicore-filelistitem"]').should('have.lengthOf', 1) +}) + +Then('the button is disabled', () => { + cy.get('[data-test="dhis2-uicore-button"]').should('have.attr', 'disabled') +}) diff --git a/cypress/integration/FileInputFieldWithList/Displays_files_in_a_list.feature b/cypress/integration/FileInputFieldWithList/Displays_files_in_a_list.feature new file mode 100644 index 0000000000..07da37a392 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/Displays_files_in_a_list.feature @@ -0,0 +1,5 @@ +Feature: The FileInputFieldWithList displays files in a list + + Scenario: Files are displayed in a list + Given a FileInputFieldWithList with multiple files is rendered + Then the files are displayed in a list \ No newline at end of file diff --git a/cypress/integration/FileInputFieldWithList/Displays_files_in_a_list/index.js b/cypress/integration/FileInputFieldWithList/Displays_files_in_a_list/index.js new file mode 100644 index 0000000000..0c835dded2 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/Displays_files_in_a_list/index.js @@ -0,0 +1,8 @@ +import '../common' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('the files are displayed in a list', () => { + cy.get('[data-test="dhis2-uicore-filelistitem"] .label') + .then($labels => $labels.map((_, el) => el.innerHTML).toArray()) + .should('deep.equal', ['test1.md', 'duplicate.md', 'test2.md']) +}) diff --git a/cypress/integration/FileInputFieldWithList/Files_can_be_removed.feature b/cypress/integration/FileInputFieldWithList/Files_can_be_removed.feature new file mode 100644 index 0000000000..82ee4321a2 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/Files_can_be_removed.feature @@ -0,0 +1,7 @@ +Feature: Files can be removed from FileInputFieldWithList + + Scenario: Files can be removed from the file list + Given a FileInputFieldWithList with multiple files is rendered + When the remove handle behind a file is clicked + Then the onChange handler is called + Then the onChange handler's payload does not contain an entry for that file \ No newline at end of file diff --git a/cypress/integration/FileInputFieldWithList/Files_can_be_removed/index.js b/cypress/integration/FileInputFieldWithList/Files_can_be_removed/index.js new file mode 100644 index 0000000000..eac0727218 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/Files_can_be_removed/index.js @@ -0,0 +1,19 @@ +import '../common' +import { When, Then } from 'cypress-cucumber-preprocessor/steps' + +When('the remove handle behind a file is clicked', () => { + cy.contains('test1.md') + .siblings('[data-test="dhis2-uicore-filelistitem-remove"]') + .click() +}) + +Then( + "the onChange handler's payload does not contain an entry for that file", + () => { + cy.get('@payload') + .its('files') + .should('have.lengthOf', 2) + .then(files => files.filter(f => f.name === 'test1.md')) + .should('have.lengthOf', 0) + } +) diff --git a/cypress/integration/FileInputFieldWithList/common/index.js b/cypress/integration/FileInputFieldWithList/common/index.js new file mode 100644 index 0000000000..745ae5c496 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/common/index.js @@ -0,0 +1,20 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileInputFieldWithList with multiple files is rendered', () => { + cy.visitStory('FileInputFieldWithList', 'Multiple files - with files') +}) + +Then('the onChange handler is called', () => { + cy.window().then(win => { + const calls = win.onChange.getCalls() + expect(calls).to.have.lengthOf(1) + + const callArgs = calls[0].args + expect(callArgs).to.have.lengthOf(2) + + const payload = callArgs[0] + expect(Object.keys(payload)).to.include.members(['files', 'name']) + + cy.wrap(payload).as('payload') + }) +}) diff --git a/cypress/integration/FileList/Accepts_children.feature b/cypress/integration/FileList/Accepts_children.feature new file mode 100644 index 0000000000..36b59aca33 --- /dev/null +++ b/cypress/integration/FileList/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The FileList renders children + + Scenario: A FileList with children + Given a FileList with children is rendered + Then the children are visible diff --git a/cypress/integration/FileList/Accepts_children/index.js b/cypress/integration/FileList/Accepts_children/index.js new file mode 100644 index 0000000000..50e9a96396 --- /dev/null +++ b/cypress/integration/FileList/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileList with children is rendered', () => { + cy.visitStory('FileList', 'With children') + cy.get('[data-test="dhis2-uicore-filelist"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/FileListItem/Accepts_cancel_text.feature b/cypress/integration/FileListItem/Accepts_cancel_text.feature new file mode 100644 index 0000000000..b55b5beba9 --- /dev/null +++ b/cypress/integration/FileListItem/Accepts_cancel_text.feature @@ -0,0 +1,5 @@ +Feature: The FileListItem accepts cancel text + + Scenario: A FileListItem with cancel text + Given a loading FileListItem with onCancel handler and cancelText is rendered + Then the cancelText will be visible diff --git a/cypress/integration/FileListItem/Accepts_cancel_text/index.js b/cypress/integration/FileListItem/Accepts_cancel_text/index.js new file mode 100644 index 0000000000..609bd5c8a8 --- /dev/null +++ b/cypress/integration/FileListItem/Accepts_cancel_text/index.js @@ -0,0 +1,14 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a loading FileListItem with onCancel handler and cancelText is rendered', + () => { + cy.visitStory('FileListItem', 'Loading with onCancel and cancelText') + } +) + +Then('the cancelText will be visible', () => { + cy.get('[data-test="dhis2-uicore-filelistitem-cancel"]') + .contains('Cancel') + .should('be.visible') +}) diff --git a/cypress/integration/FileListItem/Accepts_label.feature b/cypress/integration/FileListItem/Accepts_label.feature new file mode 100644 index 0000000000..67d899fbd1 --- /dev/null +++ b/cypress/integration/FileListItem/Accepts_label.feature @@ -0,0 +1,5 @@ +Feature: The FileListItem accepts label + + Scenario: A FileListItem with label text + Given a FileListItem with label is rendered + Then the label will be visible diff --git a/cypress/integration/FileListItem/Accepts_label/index.js b/cypress/integration/FileListItem/Accepts_label/index.js new file mode 100644 index 0000000000..6acc6c3ac6 --- /dev/null +++ b/cypress/integration/FileListItem/Accepts_label/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileListItem with label is rendered', () => { + cy.visitStory('FileListItem', 'With label') +}) + +Then('the label will be visible', () => { + cy.get('[data-test="dhis2-uicore-filelistitem"]') + .contains('Label') + .should('be.visible') +}) diff --git a/cypress/integration/FileListItem/Accepts_remove_text.feature b/cypress/integration/FileListItem/Accepts_remove_text.feature new file mode 100644 index 0000000000..5a682afd7b --- /dev/null +++ b/cypress/integration/FileListItem/Accepts_remove_text.feature @@ -0,0 +1,5 @@ +Feature: The FileListItem accepts remove text + + Scenario: A FileListItem with remove text + Given a FileListItem with removeText is rendered + Then the removeText will be visible diff --git a/cypress/integration/FileListItem/Accepts_remove_text/index.js b/cypress/integration/FileListItem/Accepts_remove_text/index.js new file mode 100644 index 0000000000..71b69833e8 --- /dev/null +++ b/cypress/integration/FileListItem/Accepts_remove_text/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileListItem with removeText is rendered', () => { + cy.visitStory('FileListItem', 'With removeText') +}) + +Then('the removeText will be visible', () => { + cy.get('[data-test="dhis2-uicore-filelistitem-remove"]') + .contains('Remove') + .should('be.visible') +}) diff --git a/cypress/integration/FileListItem/Can_be_removed.feature b/cypress/integration/FileListItem/Can_be_removed.feature new file mode 100644 index 0000000000..f8182483b9 --- /dev/null +++ b/cypress/integration/FileListItem/Can_be_removed.feature @@ -0,0 +1,6 @@ +Feature: The FileListItem has an onRemove api + + Scenario: The user removes a file + Given a FileListItem with Onremove handler is rendered + When the user clicks on the remove text + Then the onRemove handler is called diff --git a/cypress/integration/FileListItem/Can_be_removed/index.js b/cypress/integration/FileListItem/Can_be_removed/index.js new file mode 100644 index 0000000000..0aba5d79cc --- /dev/null +++ b/cypress/integration/FileListItem/Can_be_removed/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileListItem with Onremove handler is rendered', () => { + cy.visitStory('FileListItem', 'With onRemove') +}) + +When('the user clicks on the remove text', () => { + cy.get('[data-test="dhis2-uicore-filelistitem-remove"]').click() +}) + +Then('the onRemove handler is called', () => { + cy.window().then(win => { + expect(win.onRemove).to.be.calledWith({}) + }) +}) diff --git a/cypress/integration/FileListItem/Loading_can_be_cancelled.feature b/cypress/integration/FileListItem/Loading_can_be_cancelled.feature new file mode 100644 index 0000000000..ce085cd4b5 --- /dev/null +++ b/cypress/integration/FileListItem/Loading_can_be_cancelled.feature @@ -0,0 +1,6 @@ +Feature: The FileListItem has an onCancel api + + Scenario: The user cancels a file + Given a loading FileListItem with onCancel handler is rendered + When the user clicks on the cancel text + Then the onCancel handler is called diff --git a/cypress/integration/FileListItem/Loading_can_be_cancelled/index.js b/cypress/integration/FileListItem/Loading_can_be_cancelled/index.js new file mode 100644 index 0000000000..bc45a3f775 --- /dev/null +++ b/cypress/integration/FileListItem/Loading_can_be_cancelled/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a loading FileListItem with onCancel handler is rendered', () => { + cy.visitStory('FileListItem', 'Loading with onCancel') +}) + +When('the user clicks on the cancel text', () => { + cy.get('[data-test="dhis2-uicore-filelistitem-cancel"]').click() +}) + +Then('the onCancel handler is called', () => { + cy.window().then(win => { + expect(win.onCancel).to.be.calledWith({}) + }) +}) diff --git a/cypress/integration/FileListPlaceholder/Accepts_children.feature b/cypress/integration/FileListPlaceholder/Accepts_children.feature new file mode 100644 index 0000000000..5a515f92b6 --- /dev/null +++ b/cypress/integration/FileListPlaceholder/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The FileListPlaceholder renders children + + Scenario: A FileListPlaceholder with children + Given a FileListPlaceholder with children is rendered + Then the children are visible diff --git a/cypress/integration/FileListPlaceholder/Accepts_children/index.js b/cypress/integration/FileListPlaceholder/Accepts_children/index.js new file mode 100644 index 0000000000..7432da2b1a --- /dev/null +++ b/cypress/integration/FileListPlaceholder/Accepts_children/index.js @@ -0,0 +1,12 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FileListPlaceholder with children is rendered', () => { + cy.visitStory('FileListPlaceholder', 'With children') + cy.get('[data-test="dhis2-uicore-filelistplaceholder"]').should( + 'be.visible' + ) +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps.feature b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps.feature new file mode 100644 index 0000000000..3e29a0a7b4 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps.feature @@ -0,0 +1,22 @@ +Feature: The HeaderBar contains a menu to all apps + + Scenario: The HeaderBar contains a menu icon + Given the HeaderBar loads without an error + Then the HeaderBar displays a menu icon + + Scenario: The menu is closed by default + Given the HeaderBar loads without an error + Then the HeaderBar dos not display the app menu + + Scenario: The user will be offered a menu with 5 apps + Given there are 5 apps available to the user + And the HeaderBar loads without an error + When the user clicks on the menu icons + Then the menu opens + And contains 5 items with links + + Scenario: The app menu closes when the user clicks outside + Given the HeaderBar loads without an error + When the user opens the menu + And the user clicks outside of the menu + Then the HeaderBar dos not display the app menu diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_HeaderBar_contains_a_menu_icon.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_HeaderBar_contains_a_menu_icon.js new file mode 100644 index 0000000000..2b6d61bfbd --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_HeaderBar_contains_a_menu_icon.js @@ -0,0 +1,6 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('the HeaderBar displays a menu icon', () => { + cy.get('[data-test="headerbar-apps-icon"]').should('exist') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_app_menu_closes_when_the_user_clicks_outside.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_app_menu_closes_when_the_user_clicks_outside.js new file mode 100644 index 0000000000..77c797394b --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_app_menu_closes_when_the_user_clicks_outside.js @@ -0,0 +1,10 @@ +import '../common/index' +import { When } from 'cypress-cucumber-preprocessor/steps' + +When('the user opens the menu', () => { + cy.get('[data-test="headerbar-apps-icon"]').click() +}) + +When('the user clicks outside of the menu', () => { + cy.get('[data-test="headerbar-title"]').click() +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_menu_is_closed_by_default.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_menu_is_closed_by_default.js new file mode 100644 index 0000000000..a3de93713c --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_menu_is_closed_by_default.js @@ -0,0 +1,7 @@ +import '../common/index.js' +import './common.js' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('the HeaderBar dos not display the app menu', () => { + cy.get('[data-test="headerbar-apps-menu"]').should('not.exist') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_user_will_be_offered_a_menu_with_5_apps.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_user_will_be_offered_a_menu_with_5_apps.js new file mode 100644 index 0000000000..44429141d2 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/The_user_will_be_offered_a_menu_with_5_apps.js @@ -0,0 +1,25 @@ +import '../common/index.js' +import { When, Then, Given } from 'cypress-cucumber-preprocessor/steps' + +Given('there are 5 apps available to the user', () => { + cy.fixture('HeaderBar/getModules') + .then(response => ({ + ...response, + modules: response.modules.slice(0, 5), + })) + .as('modulesFixture') +}) + +When('the user clicks on the menu icons', () => { + cy.get('[data-test="headerbar-apps-icon"]').click() +}) + +Then('the menu opens', () => { + cy.get('[data-test="headerbar-apps-menu"]').should('be.visible') +}) + +Then('contains 5 items with links', () => { + cy.get('[data-test="headerbar-apps-menu-list"]') + .find('a') + .should('have.length', 5) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/common.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/common.js new file mode 100644 index 0000000000..e7054b39cf --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_menu_to_all_apps/common.js @@ -0,0 +1,5 @@ +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('the HeaderBar dos not display the app menu', () => { + cy.get('[data-test="headerbar-apps-menu"]').should('not.exist') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu.feature b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu.feature new file mode 100644 index 0000000000..846736cadf --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu.feature @@ -0,0 +1,56 @@ +Feature: The HeaderBar contains a profile menu + + Scenario: The HeaderBar contains a profile icon + Given the HeaderBar loads without an error + Then the HeaderBar displays a profile icon + + Scenario: The menu is closed by default + Given the HeaderBar loads without an error + Then the HeaderBar does not display the profile menu + + Scenario: The menu opens + Given the HeaderBar loads without an error + When the user clicks on the profile icons + Then the menu opens + + Scenario: The user name and email are displayed + Given the HeaderBar loads without an error + When the user opens the menu + And contains the user name + And contains the user email + + Scenario: The user can edit his profile + Given the HeaderBar loads without an error + When the user opens the menu + Then contains a link to edit the profile + + Scenario: The user can go to the settings + Given the HeaderBar loads without an error + When the user opens the menu + Then contains a link to the settings + + Scenario: The user can go to his account + Given the HeaderBar loads without an error + When the user opens the menu + Then contains a link to the user account + + Scenario: The user can go to the help page + Given the HeaderBar loads without an error + When the user opens the menu + Then contains a link to the help page + + Scenario: The user can go to the About DHIS2 page + Given the HeaderBar loads without an error + When the user opens the menu + Then contains a link to the About DHIS2 page + + Scenario: The user can log out + Given the HeaderBar loads without an error + When the user opens the menu + Then contains a link to log out the user + + Scenario: The profile menu closes when the user clicks outside + Given the HeaderBar loads without an error + When the user opens the menu + And the user clicks outside of the menu + Then the HeaderBar does not display the profile menu diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_HeaderBar_contains_a_profile_icon.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_HeaderBar_contains_a_profile_icon.js new file mode 100644 index 0000000000..2b832c78f3 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_HeaderBar_contains_a_profile_icon.js @@ -0,0 +1,11 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('the HeaderBar displays a profile icon', () => { + cy.get( + ` + [data-test="headerbar-profile-icon-text"], + [data-test="headerbar-profile-icon-image"] + ` + ).should('be.visible') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_menu_is_closed_by_default.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_menu_is_closed_by_default.js new file mode 100644 index 0000000000..45c94ab835 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_menu_is_closed_by_default.js @@ -0,0 +1,3 @@ +import '../common/index.js' + +// all step definitions are shared with other scenarios diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_menu_opens.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_menu_opens.js new file mode 100644 index 0000000000..00e8faaef9 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_menu_opens.js @@ -0,0 +1,15 @@ +import '../common/index' +import { When, Then } from 'cypress-cucumber-preprocessor/steps' + +When('the user clicks on the profile icons', () => { + cy.get( + ` + [data-test="headerbar-profile-icon-text"], + [data-test="headerbar-profile-icon-image"] + ` + ).click() +}) + +Then('the menu opens', () => { + cy.get('[data-test="headerbar-profile-menu"]').should('be.visible') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_profile_menu_closes_when_the_user_clicks_outside.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_profile_menu_closes_when_the_user_clicks_outside.js new file mode 100644 index 0000000000..c5cc1e0f3b --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_profile_menu_closes_when_the_user_clicks_outside.js @@ -0,0 +1,6 @@ +import '../common/index' +import { When } from 'cypress-cucumber-preprocessor/steps' + +When('the user clicks outside of the menu', () => { + cy.get('[data-test="headerbar-title"]').click() +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_edit_his_profile.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_edit_his_profile.js new file mode 100644 index 0000000000..24dd822975 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_edit_his_profile.js @@ -0,0 +1,8 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('contains a link to edit the profile', () => { + cy.get('[data-test="headerbar-profile-edit-profile-link"]').should( + 'be.visible' + ) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_his_account.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_his_account.js new file mode 100644 index 0000000000..9d41e029c5 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_his_account.js @@ -0,0 +1,8 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('contains a link to the user account', () => { + cy.get('[data-test="headerbar-profile-menu"] > li').should(lis => { + expect(lis.eq(1)).to.be.visible + }) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_the_About_DHIS2_page.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_the_About_DHIS2_page.js new file mode 100644 index 0000000000..0751435a02 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_the_About_DHIS2_page.js @@ -0,0 +1,8 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('contains a link to the About DHIS2 page', () => { + cy.get('[data-test="headerbar-profile-menu"] > li').should(lis => { + expect(lis.eq(3)).to.be.visible + }) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_the_help_page.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_the_help_page.js new file mode 100644 index 0000000000..a389526878 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_the_help_page.js @@ -0,0 +1,8 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('contains a link to the help page', () => { + cy.get('[data-test="headerbar-profile-menu"] > li').should(lis => { + expect(lis.eq(2)).to.be.visible + }) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_the_settings.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_the_settings.js new file mode 100644 index 0000000000..e051eb7fb8 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_go_to_the_settings.js @@ -0,0 +1,8 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('contains a link to the settings', () => { + cy.get('[data-test="headerbar-profile-menu"] > li').should(lis => { + expect(lis.eq(0)).to.be.visible + }) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_log_out.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_log_out.js new file mode 100644 index 0000000000..8fc29cfb7c --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_can_log_out.js @@ -0,0 +1,8 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('contains a link to log out the user', () => { + cy.get('[data-test="headerbar-profile-menu"] > li').should(lis => { + expect(lis.eq(4)).to.be.visible + }) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_name_and_email_are_displayed.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_name_and_email_are_displayed.js new file mode 100644 index 0000000000..d36cedfbe5 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/The_user_name_and_email_are_displayed.js @@ -0,0 +1,18 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('contains the user name', () => { + cy.get('@meFixture').then(({ name }) => { + cy.get('[data-test="headerbar-profile-username"]').then($name => { + expect($name.text()).to.equal(name) + }) + }) +}) + +Then('contains the user email', () => { + cy.get('@meFixture').then(({ email }) => { + cy.get('[data-test="headerbar-profile-user-email"]').then($email => { + expect($email.text()).to.equal(email) + }) + }) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/common.js b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/common.js new file mode 100644 index 0000000000..8b39d3cc1b --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_contains_a_profile_menu/common.js @@ -0,0 +1,14 @@ +import { Then, When } from 'cypress-cucumber-preprocessor/steps' + +Then('the HeaderBar does not display the profile menu', () => { + cy.get('[data-test="headerbar-profile-menu"]').should('not.exist') +}) + +When('the user opens the menu', () => { + cy.get( + ` + [data-test="headerbar-profile-icon-text"], + [data-test="headerbar-profile-icon-image"] + ` + ).click() +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count.feature b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count.feature new file mode 100644 index 0000000000..0fba67d52e --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count.feature @@ -0,0 +1,15 @@ +Feature: The HeaderBar displays a link to interpretations and an unread count + + Scenario: The HeaderBar displays a link to the interpretations + Given the HeaderBar loads without an error + Then the HeaderBar contains a link to the interpretations + + Scenario: There are some unread interpretations + Given there are 10 unread interpretations + And the HeaderBar loads without an error + Then the interpretations link contains an icon with the number 10 + + Scenario: There are no unread interpretations + Given there are 0 unread interpretations + And the HeaderBar loads without an error + Then the interpretations link does not contain a count diff --git a/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count/The_HeaderBar_displays_a_link_to_the_interpretations.js b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count/The_HeaderBar_displays_a_link_to_the_interpretations.js new file mode 100644 index 0000000000..69a7845c9c --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count/The_HeaderBar_displays_a_link_to_the_interpretations.js @@ -0,0 +1,6 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('the HeaderBar contains a link to the interpretations', () => { + cy.get('[data-test="headerbar-interpretations"]').should('be.visible') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count/There_are_no_unread_interpretations.js b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count/There_are_no_unread_interpretations.js new file mode 100644 index 0000000000..91178e5f15 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count/There_are_no_unread_interpretations.js @@ -0,0 +1,15 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('there are 0 unread interpretations', () => { + cy.fixture('HeaderBar/dashboard') + .then(response => ({ + ...response, + unreadInterpretations: 0, + })) + .as('dashboardFixture') +}) + +Then('the interpretations link does not contain a count', () => { + cy.get('[data-test="headerbar-interpretations-count"]').should('not.exist') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count/There_are_some_unread_interpretations.js b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count/There_are_some_unread_interpretations.js new file mode 100644 index 0000000000..2b4e9312b6 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_interpretations_and_an_unread_count/There_are_some_unread_interpretations.js @@ -0,0 +1,17 @@ +import '../common/index' +import { Then, Given } from 'cypress-cucumber-preprocessor/steps' + +Given('there are 10 unread interpretations', () => { + cy.fixture('HeaderBar/dashboard') + .then(response => ({ + ...response, + unreadInterpretations: 10, + })) + .as('dashboardFixture') +}) + +Then('the interpretations link contains an icon with the number 10', () => { + cy.get('[data-test="headerbar-interpretations-count"]').then($count => { + expect($count.text()).to.equal('10') + }) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count.feature b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count.feature new file mode 100644 index 0000000000..4a807269c7 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count.feature @@ -0,0 +1,15 @@ +Feature: The HeaderBar displays a link to messages and an unread count + + Scenario: The HeaderBar displays a link to the messages + Given the HeaderBar loads without an error + Then the HeaderBar contains a link to the messages + + Scenario: There are some unread messages + Given the HeaderBar loads without an error + And there are 5 unread messages + Then the messages link contains an icon with the number 5 + + Scenario: There are no unread messages + Given the HeaderBar loads without an error + And there are 0 unread messages + Then the messages link does not contain a count diff --git a/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count/The_HeaderBar_displays_a_link_to_the_messages.js b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count/The_HeaderBar_displays_a_link_to_the_messages.js new file mode 100644 index 0000000000..5c65f16909 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count/The_HeaderBar_displays_a_link_to_the_messages.js @@ -0,0 +1,6 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('the HeaderBar contains a link to the messages', () => { + cy.get('[data-test="headerbar-messages"]').should('be.visible') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count/There_are_no_unread_messages.js b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count/There_are_no_unread_messages.js new file mode 100644 index 0000000000..7cf7d8a48f --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count/There_are_no_unread_messages.js @@ -0,0 +1,15 @@ +import '../common/index' +import { Then, Given } from 'cypress-cucumber-preprocessor/steps' + +Given('there are 0 unread messages', () => { + cy.fixture('HeaderBar/dashboard') + .then(response => ({ + ...response, + unreadMessages: 0, + })) + .as('dashboardFixture') +}) + +Then('the messages link does not contain a count', () => { + cy.get('[data-test="headerbar-messages-count"]').should('not.exist') +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count/There_are_some_unread_messages.js b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count/There_are_some_unread_messages.js new file mode 100644 index 0000000000..da8054f5b1 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_displays_a_link_to_messages_and_an_unread_count/There_are_some_unread_messages.js @@ -0,0 +1,17 @@ +import '../common/index' +import { Then, Given } from 'cypress-cucumber-preprocessor/steps' + +Given('there are 5 unread messages', () => { + cy.fixture('HeaderBar/dashboard') + .then(response => ({ + ...response, + unreadMessages: 5, + })) + .as('dashboardFixture') +}) + +Then('the messages link contains an icon with the number 5', () => { + cy.get('[data-test="headerbar-messages-count"]').then($count => { + expect($count.text()).to.equal('5') + }) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_should_contain_a_logo_that_links_to_the_homepage.feature b/cypress/integration/HeaderBar/The_HeaderBar_should_contain_a_logo_that_links_to_the_homepage.feature new file mode 100644 index 0000000000..dd4cca83d2 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_should_contain_a_logo_that_links_to_the_homepage.feature @@ -0,0 +1,6 @@ +Feature: The HeaderBar should contain a logo that links to the homepage + + Scenario: HeaderBar contains logo + Given the HeaderBar loads without an error + Then the HeaderBar should display the dhis2 logo + And the logo should link to the homepage diff --git a/cypress/integration/HeaderBar/The_HeaderBar_should_contain_a_logo_that_links_to_the_homepage/HeaderBar_contains_logo.js b/cypress/integration/HeaderBar/The_HeaderBar_should_contain_a_logo_that_links_to_the_homepage/HeaderBar_contains_logo.js new file mode 100644 index 0000000000..ad86c86b03 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_should_contain_a_logo_that_links_to_the_homepage/HeaderBar_contains_logo.js @@ -0,0 +1,14 @@ +import '../common/index.js' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('the HeaderBar should display the dhis2 logo', () => { + cy.get('[data-test="headerbar-logo"]').should('be.visible') +}) + +Then('the logo should link to the homepage', () => { + cy.get('@systemInfoFixture').then(({ contextPath }) => { + cy.get('[data-test="headerbar-logo"] a').then($a => { + expect($a.attr('href')).to.equal(contextPath) + }) + }) +}) diff --git a/cypress/integration/HeaderBar/The_HeaderBar_should_display_the_title_provided_by_the_backend_and_the_app.feature b/cypress/integration/HeaderBar/The_HeaderBar_should_display_the_title_provided_by_the_backend_and_the_app.feature new file mode 100644 index 0000000000..d1e027f7cb --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_should_display_the_title_provided_by_the_backend_and_the_app.feature @@ -0,0 +1,6 @@ +Feature: The HeaderBar should display the title provided by the backend and the app + + Scenario: The HeaderBar displays the custom title + Given the custom title is "Barbaz" and the app title is "Example!" + And the HeaderBar loads without an error + Then the displayed title should be "Barbaz - Example!" diff --git a/cypress/integration/HeaderBar/The_HeaderBar_should_display_the_title_provided_by_the_backend_and_the_app/The_HeaderBar_displays_the_custom_title.js b/cypress/integration/HeaderBar/The_HeaderBar_should_display_the_title_provided_by_the_backend_and_the_app/The_HeaderBar_displays_the_custom_title.js new file mode 100644 index 0000000000..b18ef51917 --- /dev/null +++ b/cypress/integration/HeaderBar/The_HeaderBar_should_display_the_title_provided_by_the_backend_and_the_app/The_HeaderBar_displays_the_custom_title.js @@ -0,0 +1,20 @@ +import '../common/index' +import { Then, Given } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'the custom title is {string} and the app title is "Example!"', + systemName => { + cy.fixture('HeaderBar/systemInfo') + .then(response => ({ + ...response, + systemName, + })) + .as('systemInfoFixture') + } +) + +Then('the displayed title should be "Barbaz - Example!"', () => { + cy.get('[data-test="headerbar-title"]').then($title => { + expect($title.text()).to.equal('Barbaz - Example!') + }) +}) diff --git a/cypress/integration/HeaderBar/common/index.js b/cypress/integration/HeaderBar/common/index.js new file mode 100644 index 0000000000..b98f991f08 --- /dev/null +++ b/cypress/integration/HeaderBar/common/index.js @@ -0,0 +1,72 @@ +import { Before, Given } from 'cypress-cucumber-preprocessor/steps' + +/** + * Will be executed before any `Given` statement, + * so these can be overriden by using a different fixture, e. g: + * + * Given('foo bar baz', () => { + * cy.fixture('HeaderBar/systemInfoBarbaz').as('systemInfoFixture') + * }) + * + * or + * + * Given('foo bar baz', () => { + * cy.fixture('HeaderBar/systemInfo') + * then(response => ({ + * ...response, + * foo: { + * ...response.foo, + * bar: 'baz' + * } + * })).as('systemInfoFixture') + * }) + * + */ +Before(() => { + cy.fixture('HeaderBar/systemInfo').as('systemInfoFixture') + cy.fixture('HeaderBar/me').as('meFixture') + cy.fixture('HeaderBar/getModules').as('modulesFixture') + cy.fixture('HeaderBar/dashboard').as('dashboardFixture') + cy.fixture('HeaderBar/logo_banner').as('logoFixture') +}) + +Given('the HeaderBar loads without an error', () => { + cy.server() + + cy.get('@systemInfoFixture').then(fx => { + cy.route({ + url: 'https://domain.tld/api/system/info', + response: fx, + }).as('systemInfo') + }) + + cy.get('@meFixture').then(fx => { + cy.route({ + url: 'https://domain.tld/api/me', + response: fx, + }).as('systemInfo') + }) + + cy.get('@modulesFixture').then(fx => { + cy.route({ + url: 'https://domain.tld/dhis-web-commons/menu/getModules.action', + response: fx, + }).as('modules') + }) + + cy.get('@dashboardFixture').then(fx => { + cy.route({ + url: 'https://domain.tld/api/me/dashboard', + response: fx, + }).as('dashboard') + }) + + cy.get('@logoFixture').then(fx => { + cy.route({ + url: 'https://domain.tld/api/staticContent/logo_banner', + response: fx, + }).as('logo_banner') + }) + + cy.visitStory('HeaderBarTesting', 'Default') +}) diff --git a/cypress/integration/Help/Accepts_children.feature b/cypress/integration/Help/Accepts_children.feature new file mode 100644 index 0000000000..14c6d80980 --- /dev/null +++ b/cypress/integration/Help/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Help component renders children + + Scenario: A Help component with children + Given a Help component with children is rendered + Then the children are visible diff --git a/cypress/integration/Help/Accepts_children/index.js b/cypress/integration/Help/Accepts_children/index.js new file mode 100644 index 0000000000..9d004378a3 --- /dev/null +++ b/cypress/integration/Help/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Help component with children is rendered', () => { + cy.visitStory('Help', 'With children') + cy.get('[data-test="dhis2-uicore-help"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Input/Accepts_initial_focus.feature b/cypress/integration/Input/Accepts_initial_focus.feature new file mode 100644 index 0000000000..c691afa4d7 --- /dev/null +++ b/cypress/integration/Input/Accepts_initial_focus.feature @@ -0,0 +1,5 @@ +Feature: Focusing the Input on mount + + Scenario: The Input renders with focus + Given a Input with initialFocus is rendered + Then the Input is focused diff --git a/cypress/integration/Input/Accepts_initial_focus/index.js b/cypress/integration/Input/Accepts_initial_focus/index.js new file mode 100644 index 0000000000..b91592c835 --- /dev/null +++ b/cypress/integration/Input/Accepts_initial_focus/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Input with initialFocus is rendered', () => { + cy.visitStory('Input', 'With initialFocus') +}) + +Then('the Input is focused', () => { + cy.focused() + .parent('[data-test="dhis2-uicore-input"]') + .should('exist') +}) diff --git a/cypress/integration/Input/Can_be_blurred.feature b/cypress/integration/Input/Can_be_blurred.feature new file mode 100644 index 0000000000..9adec263b3 --- /dev/null +++ b/cypress/integration/Input/Can_be_blurred.feature @@ -0,0 +1,6 @@ +Feature: The Input has an onBlur api + + Scenario: The user blurs the Input + Given an Input with initialFocus and onBlur handler is rendered + When the Input is blurred + Then the onBlur handler is called diff --git a/cypress/integration/Input/Can_be_blurred/index.js b/cypress/integration/Input/Can_be_blurred/index.js new file mode 100644 index 0000000000..efb7c659ce --- /dev/null +++ b/cypress/integration/Input/Can_be_blurred/index.js @@ -0,0 +1,18 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an Input with initialFocus and onBlur handler is rendered', () => { + cy.visitStory('Input', 'With initialFocus and onBlur') +}) + +When('the Input is blurred', () => { + cy.get('[data-test="dhis2-uicore-input"] input').blur() +}) + +Then('the onBlur handler is called', () => { + cy.window().then(win => { + expect(win.onBlur).to.be.calledWith({ + value: '', + name: 'Default', + }) + }) +}) diff --git a/cypress/integration/Input/Can_be_changed.feature b/cypress/integration/Input/Can_be_changed.feature new file mode 100644 index 0000000000..1ad815f41f --- /dev/null +++ b/cypress/integration/Input/Can_be_changed.feature @@ -0,0 +1,6 @@ +Feature: The Input has an onChange api + + Scenario: The user types a character into the Input + Given a Input with onChange handler is rendered + When the Input is filled with a character + Then the onChange handler is called diff --git a/cypress/integration/Input/Can_be_changed/index.js b/cypress/integration/Input/Can_be_changed/index.js new file mode 100644 index 0000000000..3d9f368712 --- /dev/null +++ b/cypress/integration/Input/Can_be_changed/index.js @@ -0,0 +1,20 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Input with onChange handler is rendered', () => { + cy.visitStory('Input', 'With onChange') +}) + +When('the Input is filled with a character', () => { + cy.get('[data-test="dhis2-uicore-input"]') + .click() + .type('a') +}) + +Then('the onChange handler is called', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledWith({ + value: 'a', + name: 'Default', + }) + }) +}) diff --git a/cypress/integration/Input/Can_be_disabled.feature b/cypress/integration/Input/Can_be_disabled.feature new file mode 100644 index 0000000000..5ca13d5a89 --- /dev/null +++ b/cypress/integration/Input/Can_be_disabled.feature @@ -0,0 +1,6 @@ +Feature: The Input can be disabled + + Scenario: The user clicks a disabled Input + Given a disabled Input is rendered + When the user clicks the input + Then the Input is not focused diff --git a/cypress/integration/Input/Can_be_disabled/index.js b/cypress/integration/Input/Can_be_disabled/index.js new file mode 100644 index 0000000000..cb8e3454f5 --- /dev/null +++ b/cypress/integration/Input/Can_be_disabled/index.js @@ -0,0 +1,13 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a disabled Input is rendered', () => { + cy.visitStory('Input', 'With disabled') +}) + +When('the user clicks the input', () => { + cy.get('[data-test="dhis2-uicore-input"] input').click({ force: true }) +}) + +Then('the Input is not focused', () => { + cy.focused().should('not.exist') +}) diff --git a/cypress/integration/Input/Can_be_focused.feature b/cypress/integration/Input/Can_be_focused.feature new file mode 100644 index 0000000000..0cab4c9d02 --- /dev/null +++ b/cypress/integration/Input/Can_be_focused.feature @@ -0,0 +1,6 @@ +Feature: The Input has an onFocus api + + Scenario: The user focuses the Input + Given a Input with onFocus handler is rendered + When the Input is focused + Then the onFocus handler is called diff --git a/cypress/integration/Input/Can_be_focused/index.js b/cypress/integration/Input/Can_be_focused/index.js new file mode 100644 index 0000000000..b6c6b1ae79 --- /dev/null +++ b/cypress/integration/Input/Can_be_focused/index.js @@ -0,0 +1,18 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Input with onFocus handler is rendered', () => { + cy.visitStory('Input', 'With onFocus') +}) + +When('the Input is focused', () => { + cy.get('[data-test="dhis2-uicore-input"] input').focus() +}) + +Then('the onFocus handler is called', () => { + cy.window().then(win => { + expect(win.onFocus).to.be.calledWith({ + value: '', + name: 'Default', + }) + }) +}) diff --git a/cypress/integration/InputField/Can_be_required.feature b/cypress/integration/InputField/Can_be_required.feature new file mode 100644 index 0000000000..d459fcae83 --- /dev/null +++ b/cypress/integration/InputField/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the InputField + + Scenario: Rendering a InputField that is required + Given a InputField with label and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/InputField/Can_be_required/index.js b/cypress/integration/InputField/Can_be_required/index.js new file mode 100644 index 0000000000..8db08cfd59 --- /dev/null +++ b/cypress/integration/InputField/Can_be_required/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a InputField with label and a required flag is rendered', () => { + cy.visitStory('InputField', 'With label and required') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-label-required"]').should('be.visible') +}) diff --git a/cypress/integration/Label/Accepts_children.feature b/cypress/integration/Label/Accepts_children.feature new file mode 100644 index 0000000000..0c17d21d87 --- /dev/null +++ b/cypress/integration/Label/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Label renders children + + Scenario: A Label with children + Given a Label with children is rendered + Then the children are visible diff --git a/cypress/integration/Label/Accepts_children/index.js b/cypress/integration/Label/Accepts_children/index.js new file mode 100644 index 0000000000..9f77e04e4f --- /dev/null +++ b/cypress/integration/Label/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Label with children is rendered', () => { + cy.visitStory('Label', 'With children') + cy.get('[data-test="dhis2-uicore-label"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Label/Can_be_required.feature b/cypress/integration/Label/Can_be_required.feature new file mode 100644 index 0000000000..1ede56822a --- /dev/null +++ b/cypress/integration/Label/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the Label + + Scenario: Rendering a Label that is required + Given a Label with required is rendered + Then the required indicator is visible diff --git a/cypress/integration/Label/Can_be_required/index.js b/cypress/integration/Label/Can_be_required/index.js new file mode 100644 index 0000000000..8a713648b5 --- /dev/null +++ b/cypress/integration/Label/Can_be_required/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Label with required is rendered', () => { + cy.visitStory('Label', 'With required') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-label-required"]').should('be.visible') +}) diff --git a/cypress/integration/LayerContext/Layers.feature.future b/cypress/integration/LayerContext/Layers.feature.future new file mode 100644 index 0000000000..e4074032f4 --- /dev/null +++ b/cypress/integration/LayerContext/Layers.feature.future @@ -0,0 +1,35 @@ +Feature: Layers + + Scenario: Base layer z-index calculation + Given a Layer component wraps a component + And the prop zIndexBase is "0" + Then the Layer renders with a z-index of "0" + + Scenario: Application top layer z-index calculation + Given a Layer component wraps a component + And the prop zIndexBase is "2000" + Then the Layer renders with a z-index of "2000" + + Scenario: Blocking layer z-index calculation + Given a Layer component wraps a component + And the prop zIndexBase is "3000" + Then the Layer renders with a z-index of "3000" + + Scenario: Alert layer z-index calculation + Given a Layer component wraps a component + And the prop zIndexBase is "9999" + Then the Layer renders with a z-index of "9999" + + Scenario: Nested layer z-index calculation + Given a Layer component is nested inside of another Layer component + And the outer Layer component has a higher "" + And the inner Layer component has a lower "" + Then the inner Layer renders with "" + + Example: Nested z-indexes + | zIndexOuter | zIndexInner | zIndexResult | + | 0 | 2000 | 2000 | + | 2000 | 0 | 2001 | + | 3000 | 2000 | 3001 | + | 3001 | 2000 | 3002 | + | 3002 | 3000 | 3003 | diff --git a/cypress/integration/Legend/Accepts_children.feature b/cypress/integration/Legend/Accepts_children.feature new file mode 100644 index 0000000000..0ee2d27ccd --- /dev/null +++ b/cypress/integration/Legend/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Legend renders children + + Scenario: A Legend with children + Given a Legend with children is rendered + Then the children are visible diff --git a/cypress/integration/Legend/Accepts_children/index.js b/cypress/integration/Legend/Accepts_children/index.js new file mode 100644 index 0000000000..6a2bf5183c --- /dev/null +++ b/cypress/integration/Legend/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Legend with children is rendered', () => { + cy.visitStory('Legend', 'With children') + cy.get('[data-test="dhis2-uicore-legend"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Legend/Can_be_required.feature b/cypress/integration/Legend/Can_be_required.feature new file mode 100644 index 0000000000..7e807a84fa --- /dev/null +++ b/cypress/integration/Legend/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the Legend + + Scenario: Rendering a Legend that is required + Given a Legend with content and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/Legend/Can_be_required/index.js b/cypress/integration/Legend/Can_be_required/index.js new file mode 100644 index 0000000000..a5eae59568 --- /dev/null +++ b/cypress/integration/Legend/Can_be_required/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Legend with content and a required flag is rendered', () => { + cy.visitStory('Legend', 'With content and required') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-legend-required"]').should('be.visible') +}) diff --git a/cypress/integration/Menu/Accepts_children.feature b/cypress/integration/Menu/Accepts_children.feature new file mode 100644 index 0000000000..05df0febf1 --- /dev/null +++ b/cypress/integration/Menu/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Menu renders children + + Scenario: A Menu with children + Given a Menu with children is rendered + Then the children are visible diff --git a/cypress/integration/Menu/Accepts_children/index.js b/cypress/integration/Menu/Accepts_children/index.js new file mode 100644 index 0000000000..afc9b251b4 --- /dev/null +++ b/cypress/integration/Menu/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Menu with children is rendered', () => { + cy.visitStory('Menu', 'With children') + cy.get('[data-test="dhis2-uicore-menu"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/MenuItem/Accepts_href.feature b/cypress/integration/MenuItem/Accepts_href.feature new file mode 100644 index 0000000000..33086e5e65 --- /dev/null +++ b/cypress/integration/MenuItem/Accepts_href.feature @@ -0,0 +1,5 @@ +Feature: The MenuItem accepts a href prop + + Scenario: A MenuItem with href + Given a MenuItem with href is rendered + Then a link is rendered with the href diff --git a/cypress/integration/MenuItem/Accepts_href/index.js b/cypress/integration/MenuItem/Accepts_href/index.js new file mode 100644 index 0000000000..ccdf236aec --- /dev/null +++ b/cypress/integration/MenuItem/Accepts_href/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MenuItem with href is rendered', () => { + cy.visitStory('MenuItem', 'With href') +}) + +Then('a link is rendered with the href', () => { + cy.get('a') + .should('have.attr', 'href') + .and('include', 'url.test') +}) diff --git a/cypress/integration/MenuItem/Accepts_icon.feature b/cypress/integration/MenuItem/Accepts_icon.feature new file mode 100644 index 0000000000..b1ee24b291 --- /dev/null +++ b/cypress/integration/MenuItem/Accepts_icon.feature @@ -0,0 +1,5 @@ +Feature: The MenuItem accepts an icon prop + + Scenario: MenuItem renders supplied icon + Given a MenuItem supplied with an icon is rendered + Then the icon will be visible diff --git a/cypress/integration/MenuItem/Accepts_icon/index.js b/cypress/integration/MenuItem/Accepts_icon/index.js new file mode 100644 index 0000000000..d01985879d --- /dev/null +++ b/cypress/integration/MenuItem/Accepts_icon/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MenuItem supplied with an icon is rendered', () => { + cy.visitStory('MenuItem', 'With icon') + cy.get('[data-test="dhis2-uicore-menuitem"]').should('be.visible') +}) + +Then('the icon will be visible', () => { + cy.contains('Icon').should('be.visible') +}) diff --git a/cypress/integration/MenuItem/Accepts_label.feature b/cypress/integration/MenuItem/Accepts_label.feature new file mode 100644 index 0000000000..a902932447 --- /dev/null +++ b/cypress/integration/MenuItem/Accepts_label.feature @@ -0,0 +1,5 @@ +Feature: The MenuItem accepts a label prop + + Scenario: MenuItem renders supplied label + Given a MenuItem supplied with a label is rendered + Then the label will be visible diff --git a/cypress/integration/MenuItem/Accepts_label/index.js b/cypress/integration/MenuItem/Accepts_label/index.js new file mode 100644 index 0000000000..1285c48e35 --- /dev/null +++ b/cypress/integration/MenuItem/Accepts_label/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MenuItem supplied with a label is rendered', () => { + cy.visitStory('MenuItem', 'With label') + cy.get('[data-test="dhis2-uicore-menuitem"]').should('be.visible') +}) + +Then('the label will be visible', () => { + cy.contains('Label').should('be.visible') +}) diff --git a/cypress/integration/MenuItem/Accepts_target.feature b/cypress/integration/MenuItem/Accepts_target.feature new file mode 100644 index 0000000000..da133597db --- /dev/null +++ b/cypress/integration/MenuItem/Accepts_target.feature @@ -0,0 +1,5 @@ +Feature: The MenuItem accepts a target prop + + Scenario: A MenuItem with target + Given a MenuItem with target is rendered + Then a link is rendered with the target diff --git a/cypress/integration/MenuItem/Accepts_target/index.js b/cypress/integration/MenuItem/Accepts_target/index.js new file mode 100644 index 0000000000..69e2f2aa0d --- /dev/null +++ b/cypress/integration/MenuItem/Accepts_target/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MenuItem with target is rendered', () => { + cy.visitStory('MenuItem', 'With target') +}) + +Then('a link is rendered with the target', () => { + cy.get('a') + .should('have.attr', 'target') + .and('include', '_blank') +}) diff --git a/cypress/integration/MenuItem/Is_clickable.feature b/cypress/integration/MenuItem/Is_clickable.feature new file mode 100644 index 0000000000..48ca41e01b --- /dev/null +++ b/cypress/integration/MenuItem/Is_clickable.feature @@ -0,0 +1,6 @@ +Feature: The MenuItem has an onClick api + + Scenario: The user clicks on the MenuItem + Given a MenuItem with onClick handler and value is rendered + When the MenuItem is clicked + Then the onClick handler is called with value diff --git a/cypress/integration/MenuItem/Is_clickable/index.js b/cypress/integration/MenuItem/Is_clickable/index.js new file mode 100644 index 0000000000..505ea6958b --- /dev/null +++ b/cypress/integration/MenuItem/Is_clickable/index.js @@ -0,0 +1,17 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MenuItem with onClick handler and value is rendered', () => { + cy.visitStory('MenuItem', 'With onClick and value') +}) + +When('the MenuItem is clicked', () => { + cy.get('[data-test="dhis2-uicore-menuitem"]').click() +}) + +Then('the onClick handler is called with value', () => { + cy.window().then(win => { + expect(win.onClick).to.be.calledWith({ + value: 'Value', + }) + }) +}) diff --git a/cypress/integration/MenuList/Accepts_children.feature b/cypress/integration/MenuList/Accepts_children.feature new file mode 100644 index 0000000000..a07f814dea --- /dev/null +++ b/cypress/integration/MenuList/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The MenuList renders children + + Scenario: A MenuList with children + Given a MenuList with children is rendered + Then the children are visible diff --git a/cypress/integration/MenuList/Accepts_children/index.js b/cypress/integration/MenuList/Accepts_children/index.js new file mode 100644 index 0000000000..d08806e712 --- /dev/null +++ b/cypress/integration/MenuList/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MenuList with children is rendered', () => { + cy.visitStory('MenuList', 'With children') + cy.get('[data-test="dhis2-uicore-menulist"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Modal/Accepts_children.feature b/cypress/integration/Modal/Accepts_children.feature new file mode 100644 index 0000000000..b1a7bf6af1 --- /dev/null +++ b/cypress/integration/Modal/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Modal renders children + + Scenario: A Modal with children + Given a Modal with children is rendered + Then the children are visible diff --git a/cypress/integration/Modal/Accepts_children/index.js b/cypress/integration/Modal/Accepts_children/index.js new file mode 100644 index 0000000000..7b4681ea31 --- /dev/null +++ b/cypress/integration/Modal/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Modal with children is rendered', () => { + cy.visitStory('Modal', 'With children') + cy.get('[data-test="dhis2-uicore-modal"]').should('exist') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Modal/Can_be_closed.feature b/cypress/integration/Modal/Can_be_closed.feature new file mode 100644 index 0000000000..cb66fe7df8 --- /dev/null +++ b/cypress/integration/Modal/Can_be_closed.feature @@ -0,0 +1,6 @@ +Feature: The Modal has an onClose api + + Scenario: The user clicks on the Screencover + Given a Modal with onClose handler is rendered + When the Screencover is clicked + Then the onClose handler is called diff --git a/cypress/integration/Modal/Can_be_closed/index.js b/cypress/integration/Modal/Can_be_closed/index.js new file mode 100644 index 0000000000..57769159ca --- /dev/null +++ b/cypress/integration/Modal/Can_be_closed/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Modal with onClose handler is rendered', () => { + cy.visitStory('Modal', 'With onClose') +}) + +When('the Screencover is clicked', () => { + cy.get('[data-test="dhis2-uicore-screencover"]').click('topLeft') +}) + +Then('the onClose handler is called', () => { + cy.window().then(win => { + expect(win.onClose).to.be.calledWith({}) + }) +}) diff --git a/cypress/integration/ModalActions/Accepts_children.feature b/cypress/integration/ModalActions/Accepts_children.feature new file mode 100644 index 0000000000..1d189e9e35 --- /dev/null +++ b/cypress/integration/ModalActions/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The ModalActions renders children + + Scenario: A ModalActions with children + Given a ModalActions with children is rendered + Then the children are visible diff --git a/cypress/integration/ModalActions/Accepts_children/index.js b/cypress/integration/ModalActions/Accepts_children/index.js new file mode 100644 index 0000000000..e742473bb1 --- /dev/null +++ b/cypress/integration/ModalActions/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a ModalActions with children is rendered', () => { + cy.visitStory('ModalActions', 'With children') + cy.get('[data-test="dhis2-uicore-modalactions"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/ModalContent/Accepts_children.feature b/cypress/integration/ModalContent/Accepts_children.feature new file mode 100644 index 0000000000..df3eebf5ed --- /dev/null +++ b/cypress/integration/ModalContent/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The ModalContent renders children + + Scenario: A ModalContent with children + Given a ModalContent with children is rendered + Then the children are visible diff --git a/cypress/integration/ModalContent/Accepts_children/index.js b/cypress/integration/ModalContent/Accepts_children/index.js new file mode 100644 index 0000000000..fbd4bd8b39 --- /dev/null +++ b/cypress/integration/ModalContent/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a ModalContent with children is rendered', () => { + cy.visitStory('ModalContent', 'With children') + cy.get('[data-test="dhis2-uicore-modalcontent"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/ModalTitle/Accepts_children.feature b/cypress/integration/ModalTitle/Accepts_children.feature new file mode 100644 index 0000000000..75510c4071 --- /dev/null +++ b/cypress/integration/ModalTitle/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The ModalTitle renders children + + Scenario: A ModalTitle with children + Given a ModalTitle with children is rendered + Then the children are visible diff --git a/cypress/integration/ModalTitle/Accepts_children/index.js b/cypress/integration/ModalTitle/Accepts_children/index.js new file mode 100644 index 0000000000..9678536b31 --- /dev/null +++ b/cypress/integration/ModalTitle/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a ModalTitle with children is rendered', () => { + cy.visitStory('ModalTitle', 'With children') + cy.get('[data-test="dhis2-uicore-modaltitle"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/MultiSelect/Accepts_blur_cb.feature b/cypress/integration/MultiSelect/Accepts_blur_cb.feature new file mode 100644 index 0000000000..8e49e0359c --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_blur_cb.feature @@ -0,0 +1,8 @@ +Feature: Calls onBlur cb when blurred + + Scenario: Blurring a MultiSelect through clicking away + Given a MultiSelect with onBlur handler is rendered + And the MultiSelect input is clicked + And the MultiSelect has focus + When the user clicks the backdrop + Then the onBlur handler is called diff --git a/cypress/integration/MultiSelect/Accepts_blur_cb/index.js b/cypress/integration/MultiSelect/Accepts_blur_cb/index.js new file mode 100644 index 0000000000..5ee55ee694 --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_blur_cb/index.js @@ -0,0 +1,15 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with onBlur handler is rendered', () => { + cy.visitStory('MultiSelect', 'With onBlur') +}) + +Then('the onBlur handler is called', () => { + cy.window().then(win => { + expect(win.onBlur).to.be.calledOnce + expect(win.onBlur).to.be.calledWith({ + selected: [], + }) + }) +}) diff --git a/cypress/integration/MultiSelect/Accepts_focus_cb.feature b/cypress/integration/MultiSelect/Accepts_focus_cb.feature new file mode 100644 index 0000000000..22730d159b --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_focus_cb.feature @@ -0,0 +1,6 @@ +Feature: Calls onFocus cb when focused + + Scenario: Focusing a MultiSelect through clicking + Given a MultiSelect with onFocus handler is rendered + When the MultiSelect input is clicked + Then the onFocus handler is called diff --git a/cypress/integration/MultiSelect/Accepts_focus_cb/index.js b/cypress/integration/MultiSelect/Accepts_focus_cb/index.js new file mode 100644 index 0000000000..955b5dacbf --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_focus_cb/index.js @@ -0,0 +1,15 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with onFocus handler is rendered', () => { + cy.visitStory('MultiSelect', 'With onFocus') +}) + +Then('the onFocus handler is called', () => { + cy.window().then(win => { + expect(win.onFocus).to.be.calledOnce + expect(win.onFocus).to.be.calledWith({ + selected: [], + }) + }) +}) diff --git a/cypress/integration/MultiSelect/Accepts_initial_focus.feature b/cypress/integration/MultiSelect/Accepts_initial_focus.feature new file mode 100644 index 0000000000..43da18281a --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_initial_focus.feature @@ -0,0 +1,5 @@ +Feature: Focusing the MultiSelect on mount + + Scenario: The MultiSelect renders with focus + Given a MultiSelect with initial focus is rendered + Then the MultiSelect has focus diff --git a/cypress/integration/MultiSelect/Accepts_initial_focus/index.js b/cypress/integration/MultiSelect/Accepts_initial_focus/index.js new file mode 100644 index 0000000000..bb0ecee288 --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_initial_focus/index.js @@ -0,0 +1,6 @@ +import '../common' +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with initial focus is rendered', () => { + cy.visitStory('MultiSelect', 'With initialFocus') +}) diff --git a/cypress/integration/MultiSelect/Accepts_loading.feature b/cypress/integration/MultiSelect/Accepts_loading.feature new file mode 100644 index 0000000000..cd3b5066e0 --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_loading.feature @@ -0,0 +1,14 @@ +Feature: Loading status + + Scenario: The user opens a loading MultiSelect + Given a MultiSelect with options and a loading flag is rendered + When the MultiSelect input is clicked + Then the loading spinner is displayed + And the options are displayed + + Scenario: The user opens a loading MultiSelect with a loading text + Given a MultiSelect with options, a loading flag and a loading text is rendered + When the MultiSelect input is clicked + Then the loading spinner is displayed + And the loading text is displayed + And the options are displayed diff --git a/cypress/integration/MultiSelect/Accepts_loading/index.js b/cypress/integration/MultiSelect/Accepts_loading/index.js new file mode 100644 index 0000000000..a55f0d7acd --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_loading/index.js @@ -0,0 +1,23 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with options and a loading flag is rendered', () => { + cy.visitStory('MultiSelect', 'With options and loading') +}) + +Given( + 'a MultiSelect with options, a loading flag and a loading text is rendered', + () => { + cy.visitStory('MultiSelect', 'With options, loading and loading text') + } +) + +Then('the loading spinner is displayed', () => { + cy.get('[data-test="dhis2-uicore-circularloader"]').should('be.visible') +}) + +Then('the loading text is displayed', () => { + cy.get('[data-test="dhis2-uicore-multiselect-loading"]') + .contains('Loading options') + .should('be.visible') +}) diff --git a/cypress/integration/MultiSelect/Accepts_max_height.feature b/cypress/integration/MultiSelect/Accepts_max_height.feature new file mode 100644 index 0000000000..ddcf461093 --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_max_height.feature @@ -0,0 +1,11 @@ +Feature: Setting a max-height + + Scenario: The MultiSelect does not have an explicit max-height set + Given a MultiSelect with more than ten options is rendered + And the MultiSelect is open + Then the top eight options are displayed + + Scenario: The MultiSelect a max-height of 100px set + Given a MultiSelect with more than three options and a 100px max-height is rendered + And the MultiSelect is open + Then the top three options are displayed diff --git a/cypress/integration/MultiSelect/Accepts_max_height/index.js b/cypress/integration/MultiSelect/Accepts_max_height/index.js new file mode 100644 index 0000000000..6bde5ac442 --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_max_height/index.js @@ -0,0 +1,50 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with more than ten options is rendered', () => { + cy.visitStory('MultiSelect', 'With more than ten options') +}) + +Given( + 'a MultiSelect with more than three options and a 100px max-height is rendered', + () => { + cy.visitStory( + 'MultiSelect', + 'With more than three options and a 100px max-height' + ) + } +) + +Given('the MultiSelect is open', () => { + cy.get('[data-test="dhis2-uicore-select"]').click() +}) + +Then('the top eight options are displayed', () => { + cy.contains('option one').should('be.visible') + cy.contains('option two').should('be.visible') + cy.contains('option three').should('be.visible') + cy.contains('option four').should('be.visible') + cy.contains('option five').should('be.visible') + cy.contains('option six').should('be.visible') + cy.contains('option seven').should('be.visible') + cy.contains('option eight').should('be.visible') + cy.contains('option nine').should('not.be.visible') + cy.contains('option ten').should('not.be.visible') + cy.contains('option eleven').should('not.be.visible') + cy.contains('option twelve').should('not.be.visible') +}) + +Then('the top three options are displayed', () => { + cy.contains('option one').should('be.visible') + cy.contains('option two').should('be.visible') + cy.contains('option three').should('be.visible') + cy.contains('option four').should('not.be.visible') + cy.contains('option five').should('not.be.visible') + cy.contains('option six').should('not.be.visible') + cy.contains('option seven').should('not.be.visible') + cy.contains('option eight').should('not.be.visible') + cy.contains('option nine').should('not.be.visible') + cy.contains('option ten').should('not.be.visible') + cy.contains('option eleven').should('not.be.visible') + cy.contains('option twelve').should('not.be.visible') +}) diff --git a/cypress/integration/MultiSelect/Accepts_placeholder.feature b/cypress/integration/MultiSelect/Accepts_placeholder.feature new file mode 100644 index 0000000000..7ae7051029 --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_placeholder.feature @@ -0,0 +1,10 @@ +Feature: Placeholder text + + Scenario: A placeholder text with no selection + Given a MultiSelect with a placeholder and no selection is rendered + Then the placeholder is shown + + Scenario: A placeholder text with a selection + Given a MultiSelect with a placeholder and a selection is rendered + Then the placeholder is not shown + And the selection is displayed diff --git a/cypress/integration/MultiSelect/Accepts_placeholder/index.js b/cypress/integration/MultiSelect/Accepts_placeholder/index.js new file mode 100644 index 0000000000..d1a730c43f --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_placeholder/index.js @@ -0,0 +1,26 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with a placeholder and no selection is rendered', () => { + cy.visitStory('MultiSelect', 'With placeholder') +}) + +Given('a MultiSelect with a placeholder and a selection is rendered', () => { + cy.visitStory('MultiSelect', 'With placeholder and selection') +}) + +Then('the placeholder is shown', () => { + cy.get('[data-test="dhis2-uicore-multiselect-placeholder"]') + .contains('Placeholder text') + .should('be.visible') +}) + +Then('the placeholder is not shown', () => { + cy.get('[data-test="dhis2-uicore-multiselect-placeholder"]').should( + 'not.be.visible' + ) +}) + +Then('the selection is displayed', () => { + cy.contains('option one').should('be.visible') +}) diff --git a/cypress/integration/MultiSelect/Accepts_prefix.feature b/cypress/integration/MultiSelect/Accepts_prefix.feature new file mode 100644 index 0000000000..8229224869 --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_prefix.feature @@ -0,0 +1,10 @@ +Feature: Placeholder text + + Scenario: A prefix text with no selection + Given a MultiSelect with a prefix and no selection is rendered + Then the prefix is shown + + Scenario: A prefix text with a selection + Given a MultiSelect with a prefix and a selection is rendered + Then the prefix is shown + And the selection is shown diff --git a/cypress/integration/MultiSelect/Accepts_prefix/index.js b/cypress/integration/MultiSelect/Accepts_prefix/index.js new file mode 100644 index 0000000000..392cbc6b53 --- /dev/null +++ b/cypress/integration/MultiSelect/Accepts_prefix/index.js @@ -0,0 +1,20 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with a prefix and no selection is rendered', () => { + cy.visitStory('MultiSelect', 'With prefix') +}) + +Given('a MultiSelect with a prefix and a selection is rendered', () => { + cy.visitStory('MultiSelect', 'With prefix and selection') +}) + +Then('the prefix is shown', () => { + cy.get('[data-test="dhis2-uicore-multiselect-prefix"]') + .contains('Prefix text') + .should('be.visible') +}) + +Then('the selection is shown', () => { + cy.contains('option one').should('be.visible') +}) diff --git a/cypress/integration/MultiSelect/Allows_invalid_options.feature b/cypress/integration/MultiSelect/Allows_invalid_options.feature new file mode 100644 index 0000000000..3ff948eb62 --- /dev/null +++ b/cypress/integration/MultiSelect/Allows_invalid_options.feature @@ -0,0 +1,12 @@ +Feature: Invalid MultiSelect options + + Scenario: Rendering a MultiSelect with invalid options + Given a MultiSelect with invalid options is rendered + And the MultiSelect is open + Then the invalid options are displayed + + Scenario: Rendering a MultiSelect with invalid filterable options + Given a MultiSelect with invalid filterable options is rendered + And the MultiSelect is open + When the user enters a filter string + Then the invalid options are not displayed diff --git a/cypress/integration/MultiSelect/Allows_invalid_options/index.js b/cypress/integration/MultiSelect/Allows_invalid_options/index.js new file mode 100644 index 0000000000..368c67f625 --- /dev/null +++ b/cypress/integration/MultiSelect/Allows_invalid_options/index.js @@ -0,0 +1,29 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with invalid options is rendered', () => { + cy.visitStory('MultiSelect', 'With invalid options') +}) + +Given('a MultiSelect with invalid filterable options is rendered', () => { + cy.visitStory('MultiSelect', 'With invalid filterable options') +}) + +When('the user enters a filter string', () => { + cy.get('[data-test="dhis2-uicore-multiselect-filterinput"]') + .click() + .focused() + .type('invalid') +}) + +Then('the invalid options are displayed', () => { + cy.contains('invalid one').should('be.visible') + cy.contains('invalid two').should('be.visible') + cy.contains('invalid three').should('be.visible') +}) + +Then('the invalid options are not displayed', () => { + cy.contains('invalid one').should('not.be.visible') + cy.contains('invalid two').should('not.be.visible') + cy.contains('invalid three').should('not.be.visible') +}) diff --git a/cypress/integration/MultiSelect/Allows_selecting.feature b/cypress/integration/MultiSelect/Allows_selecting.feature new file mode 100644 index 0000000000..18ecb3fbb8 --- /dev/null +++ b/cypress/integration/MultiSelect/Allows_selecting.feature @@ -0,0 +1,36 @@ +Feature: Selecting options + + Scenario: The user clicks an option to select it + Given a MultiSelect with options and onChange handler is rendered + And the MultiSelect is open + When an option is clicked + Then the clicked option is selected + + Scenario: The user clicks a custom option to select it + Given a MultiSelect with custom options and onChange handler is rendered + And the MultiSelect is open + When an option is clicked + Then the clicked option is selected + + Scenario: The user clicks another option to select it + Given a MultiSelect with options, a selection and onChange handler is rendered + And the MultiSelect is open + When another option is clicked + Then the clicked option is selected as well + + Scenario: The user clicks an option to deselect it + Given a MultiSelect with options, a selection and onChange handler is rendered + And the MultiSelect is open + When the selected option is clicked + Then the selected option is deselected + + Scenario: The user clicks a chip's X to deselect it + Given a MultiSelect with options, a selection and onChange handler is rendered + When the chip's X is clicked + Then the selected option is deselected + + Scenario: The user clicks a disabled option + Given a MultiSelect with a disabled option and onChange handler is rendered + And the MultiSelect is open + When the disabled option is clicked + Then the onchange handler is not called diff --git a/cypress/integration/MultiSelect/Allows_selecting/index.js b/cypress/integration/MultiSelect/Allows_selecting/index.js new file mode 100644 index 0000000000..9b6be5c689 --- /dev/null +++ b/cypress/integration/MultiSelect/Allows_selecting/index.js @@ -0,0 +1,72 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a MultiSelect with a disabled option and onChange handler is rendered', + () => { + cy.visitStory('MultiSelect', 'With disabled option and onChange') + } +) + +Given( + 'a MultiSelect with custom options and onChange handler is rendered', + () => { + cy.visitStory('MultiSelect', 'With custom options and onChange') + } +) + +When('an option is clicked', () => { + cy.contains('option one').click() +}) + +When('the selected option is clicked', () => { + cy.get('[data-test="dhis2-uicore-backdrop"]') + .contains('option one') + .click() +}) + +When('another option is clicked', () => { + cy.contains('option two').click() +}) + +When("the chip's X is clicked", () => { + cy.get('[data-test="dhis2-uicore-chip-remove"]').click() +}) + +When('the disabled option is clicked', () => { + cy.contains('disabled option').click() +}) + +Then('the clicked option is selected', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ + selected: [{ label: 'option one', value: '1' }], + }) + }) +}) + +Then('the clicked option is selected as well', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ + selected: [ + { label: 'option one', value: '1' }, + { label: 'option two', value: '2' }, + ], + }) + }) +}) + +Then('the selected option is deselected', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ selected: [] }) + }) +}) + +Then('the onchange handler is not called', () => { + cy.window().then(win => { + expect(win.onChange).to.not.be.called + }) +}) diff --git a/cypress/integration/MultiSelect/Can_be_cleared.feature b/cypress/integration/MultiSelect/Can_be_cleared.feature new file mode 100644 index 0000000000..6a0e09ac32 --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_cleared.feature @@ -0,0 +1,6 @@ +Feature: Clearing the MultiSelect + + Scenario: The user clicks the clear button to clear the MultiSelect + Given a clearable MultiSelect with a selection and onchange handler is rendered + When the clear button is clicked + Then the MultiSelect is cleared diff --git a/cypress/integration/MultiSelect/Can_be_cleared/index.js b/cypress/integration/MultiSelect/Can_be_cleared/index.js new file mode 100644 index 0000000000..df00aeb641 --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_cleared/index.js @@ -0,0 +1,23 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a clearable MultiSelect with a selection and onchange handler is rendered', + () => { + cy.visitStory( + 'MultiSelect', + 'With clear button, selection and onChange' + ) + } +) + +When('the clear button is clicked', () => { + cy.get('[data-test="dhis2-uicore-multiselect-clear"]').click() +}) + +Then('the MultiSelect is cleared', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ selected: [] }) + }) +}) diff --git a/cypress/integration/MultiSelect/Can_be_disabled.feature b/cypress/integration/MultiSelect/Can_be_disabled.feature new file mode 100644 index 0000000000..8b1ee5b607 --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_disabled.feature @@ -0,0 +1,32 @@ +Feature: Disabled MultiSelect + + Scenario: The disabled singleselect has a selection + Given a disabled MultiSelect with options and a selection is rendered + Then the selection is displayed + + Scenario: The user clicks a disabled MultiSelect + Given a disabled MultiSelect with options is rendered + And the MultiSelect is closed + When the MultiSelect input is clicked + Then the options are not displayed + + Scenario: The user presses the down arrowkey + Given a disabled MultiSelect with options is rendered + And the MultiSelect is closed + And the MultiSelect is focused + When the down arrowkey is pressed on the focused element + Then the options are not displayed + + Scenario: The user presses the up arrowkey + Given a disabled MultiSelect with options is rendered + And the MultiSelect is closed + And the MultiSelect is focused + When the up arrowkey is pressed on the focused element + Then the options are not displayed + + Scenario: The user presses the spacebar + Given a disabled MultiSelect with options is rendered + And the MultiSelect is closed + And the MultiSelect is focused + When the spacebar is pressed on the focused element + Then the options are not displayed diff --git a/cypress/integration/MultiSelect/Can_be_disabled/index.js b/cypress/integration/MultiSelect/Can_be_disabled/index.js new file mode 100644 index 0000000000..bac019fc11 --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_disabled/index.js @@ -0,0 +1,41 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a disabled MultiSelect with options is rendered', () => { + cy.visitStory('MultiSelect', 'With options and disabled') +}) + +Given('a disabled MultiSelect with options and a selection is rendered', () => { + cy.visitStory('MultiSelect', 'With options, a selection and disabled') +}) + +Given('the MultiSelect is closed', () => { + cy.contains('option one').should('not.exist') + cy.contains('option two').should('not.exist') + cy.contains('option three').should('not.exist') +}) + +Given('the MultiSelect is focused', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').focus() + cy.focused().should('have.attr', 'data-test', 'dhis2-uicore-select-input') +}) + +When('the MultiSelect input is clicked', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +When('the down arrowkey is pressed on the focused element', () => { + cy.focused().type('{downarrow}') +}) + +When('the spacebar is pressed on the focused element', () => { + cy.focused().type(' ') +}) + +When('the up arrowkey is pressed on the focused element', () => { + cy.focused().type('{uparrow}') +}) + +Then('the selection is displayed', () => { + cy.contains('option one').should('be.visible') +}) diff --git a/cypress/integration/MultiSelect/Can_be_empty.feature b/cypress/integration/MultiSelect/Can_be_empty.feature new file mode 100644 index 0000000000..d049f8abc5 --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_empty.feature @@ -0,0 +1,16 @@ +Feature: Loading status + + Scenario: The user opens an empty MultiSelect + Given an empty MultiSelect is rendered + When the MultiSelect input is clicked + Then an empty menu is displayed + + Scenario: The user opens an empty MultiSelect with custom empty text + Given an empty MultiSelect with custom empty text is rendered + When the MultiSelect input is clicked + Then the custom empty text is displayed + + Scenario: The user opens an empty MultiSelect with a custom empty component + Given an empty MultiSelect with custom empty component is rendered + When the MultiSelect input is clicked + Then the custom empty component is displayed diff --git a/cypress/integration/MultiSelect/Can_be_empty/index.js b/cypress/integration/MultiSelect/Can_be_empty/index.js new file mode 100644 index 0000000000..e26134aa25 --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_empty/index.js @@ -0,0 +1,29 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an empty MultiSelect is rendered', () => { + cy.visitStory('MultiSelect', 'Empty') +}) + +Given('an empty MultiSelect with custom empty text is rendered', () => { + cy.visitStory('MultiSelect', 'Empty with empty text') +}) + +Given('an empty MultiSelect with custom empty component is rendered', () => { + cy.visitStory('MultiSelect', 'Empty with empty component') +}) + +Then('an empty menu is displayed', () => { + cy.get('[data-test="dhis2-uicore-backdrop"]').should('exist') +}) + +Then('the custom empty text is displayed', () => { + cy.get('[data-test="dhis2-uicore-multiselect-empty"]') + .contains('Custom empty text') + .should('be.visible') +}) + +Then('the custom empty component is displayed', () => { + cy.contains('Custom empty component').should('be.visible') + cy.get('.custom-empty').should('exist') +}) diff --git a/cypress/integration/MultiSelect/Can_be_filtered.feature b/cypress/integration/MultiSelect/Can_be_filtered.feature new file mode 100644 index 0000000000..d09d9a4c09 --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_filtered.feature @@ -0,0 +1,14 @@ +Feature: Filtering the MultiSelect options + + Scenario: The user enters a filter string to filter the options + Given a filterable MultiSelect with options is rendered + And the MultiSelect is open + When the user enters a filter string + Then the matching options are displayed + + Scenario: The user enters a filter string that doesn't match any options + Given a filterable MultiSelect with options is rendered + And the MultiSelect is open + When the user enters a filter string that doesn't match any options + Then the no match text is displayed + And the options are not displayed diff --git a/cypress/integration/MultiSelect/Can_be_filtered/index.js b/cypress/integration/MultiSelect/Can_be_filtered/index.js new file mode 100644 index 0000000000..8850fa69bc --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_filtered/index.js @@ -0,0 +1,24 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a filterable MultiSelect with options is rendered', () => { + cy.visitStory('MultiSelect', 'With filter field') +}) + +When('the user enters a filter string', () => { + cy.focused().type('one') +}) + +When("the user enters a filter string that doesn't match any options", () => { + cy.focused().type('does not exist') +}) + +Then('the matching options are displayed', () => { + cy.contains('option one').should('be.visible') + cy.contains('option two').should('not.be.visible') + cy.contains('option three').should('not.be.visible') +}) + +Then('the no match text is displayed', () => { + cy.contains('No match found').should('be.visible') +}) diff --git a/cypress/integration/MultiSelect/Can_be_opened_and_closed.feature b/cypress/integration/MultiSelect/Can_be_opened_and_closed.feature new file mode 100644 index 0000000000..c57ddeea32 --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_opened_and_closed.feature @@ -0,0 +1,41 @@ +Feature: Opening and closing the MultiSelect + + Scenario: The user clicks the MultiSelect input to display the options + Given a MultiSelect with options is rendered + And the MultiSelect is closed + When the MultiSelect input is clicked + Then the options are displayed + + Scenario: The user presses the down arrowkey to display the options + Given a MultiSelect with options is rendered + And the MultiSelect is closed + And the MultiSelect is focused + When the down arrowkey is pressed on the focused element + Then the options are displayed + + Scenario: The user presses the up arrowkey to display the options + Given a MultiSelect with options is rendered + And the MultiSelect is closed + And the MultiSelect is focused + When the up arrowkey is pressed on the focused element + Then the options are displayed + + Scenario: The user presses the spacebar to display the options + Given a MultiSelect with options is rendered + And the MultiSelect is closed + And the MultiSelect is focused + When the spacebar is pressed on the focused element + Then the options are displayed + + Scenario: The user clicks the backdrop to hide the options + Given a MultiSelect with options is rendered + And the MultiSelect is open + When the user clicks the backdrop + Then the options are not displayed + + Scenario: The user presses the escape key to hide the options + Given a MultiSelect with options is rendered + And the MultiSelect is open + And the MultiSelect is focused + When the escape key is pressed on the focused element + Then the options are not displayed diff --git a/cypress/integration/MultiSelect/Can_be_opened_and_closed/index.js b/cypress/integration/MultiSelect/Can_be_opened_and_closed/index.js new file mode 100644 index 0000000000..2d43fcfcba --- /dev/null +++ b/cypress/integration/MultiSelect/Can_be_opened_and_closed/index.js @@ -0,0 +1,33 @@ +import '../common' +import { Given, When } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with options is rendered', () => { + cy.visitStory('MultiSelect', 'With options') +}) + +Given('the MultiSelect is closed', () => { + cy.contains('option one').should('not.exist') + cy.contains('option two').should('not.exist') + cy.contains('option three').should('not.exist') +}) + +Given('the MultiSelect is focused', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').focus() + cy.focused().should('have.attr', 'data-test', 'dhis2-uicore-select-input') +}) + +When('the down arrowkey is pressed on the focused element', () => { + cy.focused().type('{downarrow}') +}) + +When('the spacebar is pressed on the focused element', () => { + cy.focused().type(' ') +}) + +When('the up arrowkey is pressed on the focused element', () => { + cy.focused().type('{uparrow}') +}) + +When('the escape key is pressed on the focused element', () => { + cy.focused().type('{esc}') +}) diff --git a/cypress/integration/MultiSelect/Shows_selections.feature b/cypress/integration/MultiSelect/Shows_selections.feature new file mode 100644 index 0000000000..4a2c06a2cf --- /dev/null +++ b/cypress/integration/MultiSelect/Shows_selections.feature @@ -0,0 +1,9 @@ +Feature: Show current selections + + Scenario: The multiselect has a single selection + Given a MultiSelect with options and a selection is rendered + Then the selection is displayed + + Scenario: The multiselect has multiple selections + Given a MultiSelect with options and multiple selections is rendered + Then the selections are displayed diff --git a/cypress/integration/MultiSelect/Shows_selections/index.js b/cypress/integration/MultiSelect/Shows_selections/index.js new file mode 100644 index 0000000000..53a51453d9 --- /dev/null +++ b/cypress/integration/MultiSelect/Shows_selections/index.js @@ -0,0 +1,19 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with options and a selection is rendered', () => { + cy.visitStory('MultiSelect', 'With options and a selection') +}) + +Given('a MultiSelect with options and multiple selections is rendered', () => { + cy.visitStory('MultiSelect', 'With options and multiple selections') +}) + +Then('the selection is displayed', () => { + cy.contains('option one').should('be.visible') +}) + +Then('the selections are displayed', () => { + cy.contains('option one').should('be.visible') + cy.contains('option two').should('be.visible') +}) diff --git a/cypress/integration/MultiSelect/common/index.js b/cypress/integration/MultiSelect/common/index.js new file mode 100644 index 0000000000..e0d0f1c5d3 --- /dev/null +++ b/cypress/integration/MultiSelect/common/index.js @@ -0,0 +1,44 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelect with options and onChange handler is rendered', () => { + cy.visitStory('MultiSelect', 'With options and onChange') +}) + +Given( + 'a MultiSelect with options, a selection and onChange handler is rendered', + () => { + cy.visitStory('MultiSelect', 'With options, a selection and onChange') + } +) + +Given('the MultiSelect is open', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() + + cy.contains('option one').should('be.visible') + cy.contains('option two').should('be.visible') + cy.contains('option three').should('be.visible') +}) + +When('the MultiSelect input is clicked', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +When('the user clicks the backdrop', () => { + cy.get('[data-test="dhis2-uicore-backdrop"]').click() +}) + +Then('the options are not displayed', () => { + cy.contains('option one').should('not.be.visible') + cy.contains('option two').should('not.be.visible') + cy.contains('option three').should('not.be.visible') +}) + +Then('the options are displayed', () => { + cy.contains('option one').should('be.visible') + cy.contains('option two').should('be.visible') + cy.contains('option three').should('be.visible') +}) + +Then('the MultiSelect has focus', () => { + cy.focused().should('have.attr', 'data-test', 'dhis2-uicore-select-input') +}) diff --git a/cypress/integration/MultiSelectField/Accepts_help_text.feature b/cypress/integration/MultiSelectField/Accepts_help_text.feature new file mode 100644 index 0000000000..91038e63aa --- /dev/null +++ b/cypress/integration/MultiSelectField/Accepts_help_text.feature @@ -0,0 +1,5 @@ +Feature: Help text for the MultiSelectField + + Scenario: Rendering a MultiSelectField with help text + Given a MultiSelectField with help text is rendered + Then the help text is visible diff --git a/cypress/integration/MultiSelectField/Accepts_help_text/index.js b/cypress/integration/MultiSelectField/Accepts_help_text/index.js new file mode 100644 index 0000000000..f2e7ed8fa4 --- /dev/null +++ b/cypress/integration/MultiSelectField/Accepts_help_text/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelectField with help text is rendered', () => { + cy.visitStory('MultiSelectField', 'With help text') +}) + +Then('the help text is visible', () => { + cy.contains('The help text').should('be.visible') +}) diff --git a/cypress/integration/MultiSelectField/Accepts_label.feature b/cypress/integration/MultiSelectField/Accepts_label.feature new file mode 100644 index 0000000000..88323a686c --- /dev/null +++ b/cypress/integration/MultiSelectField/Accepts_label.feature @@ -0,0 +1,5 @@ +Feature: Label for the MultiSelectField + + Scenario: Rendering a MultiSelectField with a label + Given a MultiSelectField with a label is rendered + Then the label is visible diff --git a/cypress/integration/MultiSelectField/Accepts_label/index.js b/cypress/integration/MultiSelectField/Accepts_label/index.js new file mode 100644 index 0000000000..b0763dda66 --- /dev/null +++ b/cypress/integration/MultiSelectField/Accepts_label/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelectField with a label is rendered', () => { + cy.visitStory('MultiSelectField', 'With label') +}) + +Then('the label is visible', () => { + cy.contains('The label').should('be.visible') +}) diff --git a/cypress/integration/MultiSelectField/Accepts_validation_text.feature b/cypress/integration/MultiSelectField/Accepts_validation_text.feature new file mode 100644 index 0000000000..493f90b854 --- /dev/null +++ b/cypress/integration/MultiSelectField/Accepts_validation_text.feature @@ -0,0 +1,5 @@ +Feature: Validation text for the MultiSelectField + + Scenario: Rendering a MultiSelectField with validation text + Given a MultiSelectField with validation text is rendered + Then the validation text is visible diff --git a/cypress/integration/MultiSelectField/Accepts_validation_text/index.js b/cypress/integration/MultiSelectField/Accepts_validation_text/index.js new file mode 100644 index 0000000000..350af8fdea --- /dev/null +++ b/cypress/integration/MultiSelectField/Accepts_validation_text/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelectField with validation text is rendered', () => { + cy.visitStory('MultiSelectField', 'With validation text') +}) + +Then('the validation text is visible', () => { + cy.contains('The validation text').should('be.visible') +}) diff --git a/cypress/integration/MultiSelectField/Can_be_required.feature b/cypress/integration/MultiSelectField/Can_be_required.feature new file mode 100644 index 0000000000..25cae04e15 --- /dev/null +++ b/cypress/integration/MultiSelectField/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the MultiSelectField + + Scenario: Rendering a MultiSelectField that is required + Given a MultiSelectField with label and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/MultiSelectField/Can_be_required/index.js b/cypress/integration/MultiSelectField/Can_be_required/index.js new file mode 100644 index 0000000000..a34123f1cd --- /dev/null +++ b/cypress/integration/MultiSelectField/Can_be_required/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MultiSelectField with label and a required flag is rendered', () => { + cy.visitStory('MultiSelectField', 'With label and required status') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-label-required"]').should('be.visible') +}) diff --git a/cypress/integration/Node/Accepts_children.feature b/cypress/integration/Node/Accepts_children.feature new file mode 100644 index 0000000000..27a0a524e9 --- /dev/null +++ b/cypress/integration/Node/Accepts_children.feature @@ -0,0 +1,9 @@ +Feature: The Node renders children + + Scenario: A closed Node with children + Given a closed Node with children is rendered + Then the children are not visible + + Scenario: An open Node with children + Given an open Node with children is rendered + Then the children are visible diff --git a/cypress/integration/Node/Accepts_children/index.js b/cypress/integration/Node/Accepts_children/index.js new file mode 100644 index 0000000000..8c8759ce10 --- /dev/null +++ b/cypress/integration/Node/Accepts_children/index.js @@ -0,0 +1,23 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a closed Node with children is rendered', () => { + cy.visitStory('Node', 'Closed with children') + cy.get('[data-test="dhis2-uicore-node"]').should('be.visible') +}) + +Given('an open Node with children is rendered', () => { + cy.visitStory('Node', 'Open with children') + cy.get('[data-test="dhis2-uicore-node"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.get('[data-test="dhis2-uicore-node-leaves"]') + .contains('I am a child') + .should('be.visible') +}) + +Then('the children are not visible', () => { + cy.get('[data-test="dhis2-uicore-node-leaves"]') + .contains('I am a child') + .should('not.be.visible') +}) diff --git a/cypress/integration/Node/Accepts_component.feature b/cypress/integration/Node/Accepts_component.feature new file mode 100644 index 0000000000..08d15b1400 --- /dev/null +++ b/cypress/integration/Node/Accepts_component.feature @@ -0,0 +1,5 @@ +Feature: The Node accepts a component prop + + Scenario: A Node with component + Given a Node with component prop is rendered + Then the component is visible diff --git a/cypress/integration/Node/Accepts_component/index.js b/cypress/integration/Node/Accepts_component/index.js new file mode 100644 index 0000000000..f5d99c2c2c --- /dev/null +++ b/cypress/integration/Node/Accepts_component/index.js @@ -0,0 +1,12 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Node with component prop is rendered', () => { + cy.visitStory('Node', 'With component') + cy.get('[data-test="dhis2-uicore-node"]').should('be.visible') +}) + +Then('the component is visible', () => { + cy.get('[data-test="dhis2-uicore-node-label"]') + .contains('I am a component') + .should('be.visible') +}) diff --git a/cypress/integration/Node/Accepts_icon.feature b/cypress/integration/Node/Accepts_icon.feature new file mode 100644 index 0000000000..30103fad40 --- /dev/null +++ b/cypress/integration/Node/Accepts_icon.feature @@ -0,0 +1,5 @@ +Feature: The Node accepts an icon prop + + Scenario: A Node with icon + Given a Node with icon prop is rendered + Then the icon is visible diff --git a/cypress/integration/Node/Accepts_icon/index.js b/cypress/integration/Node/Accepts_icon/index.js new file mode 100644 index 0000000000..03ff2a8a2b --- /dev/null +++ b/cypress/integration/Node/Accepts_icon/index.js @@ -0,0 +1,12 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Node with icon prop is rendered', () => { + cy.visitStory('Node', 'With icon') + cy.get('[data-test="dhis2-uicore-node"]').should('be.visible') +}) + +Then('the icon is visible', () => { + cy.get('[data-test="dhis2-uicore-node-icon"]') + .contains('Icon') + .should('be.visible') +}) diff --git a/cypress/integration/Node/Can_be_closed.feature b/cypress/integration/Node/Can_be_closed.feature new file mode 100644 index 0000000000..0c0e104800 --- /dev/null +++ b/cypress/integration/Node/Can_be_closed.feature @@ -0,0 +1,6 @@ +Feature: The Node has an onClose api + + Scenario: The user closes the Node + Given an open Node with an onClose handler is rendered + When the arrow is clicked + Then the onClose handler is called diff --git a/cypress/integration/Node/Can_be_closed/index.js b/cypress/integration/Node/Can_be_closed/index.js new file mode 100644 index 0000000000..e71803cdcd --- /dev/null +++ b/cypress/integration/Node/Can_be_closed/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an open Node with an onClose handler is rendered', () => { + cy.visitStory('Node', 'Open with onClose') +}) + +When('the arrow is clicked', () => { + cy.get('[data-test="dhis2-uicore-node-toggle"]').click() +}) + +Then('the onClose handler is called', () => { + cy.window().then(win => { + expect(win.onClose).to.be.calledWith({ open: false }) + }) +}) diff --git a/cypress/integration/Node/Can_be_opened.feature b/cypress/integration/Node/Can_be_opened.feature new file mode 100644 index 0000000000..7210da74c8 --- /dev/null +++ b/cypress/integration/Node/Can_be_opened.feature @@ -0,0 +1,6 @@ +Feature: The Node has an onOpen api + + Scenario: The user opens the Node + Given a closed Node with an onOpen handler is rendered + When the arrow is clicked + Then the onOpen handler is called diff --git a/cypress/integration/Node/Can_be_opened/index.js b/cypress/integration/Node/Can_be_opened/index.js new file mode 100644 index 0000000000..b8aedf29a4 --- /dev/null +++ b/cypress/integration/Node/Can_be_opened/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a closed Node with an onOpen handler is rendered', () => { + cy.visitStory('Node', 'Closed with onOpen') +}) + +When('the arrow is clicked', () => { + cy.get('[data-test="dhis2-uicore-node-toggle"]').click() +}) + +Then('the onOpen handler is called', () => { + cy.window().then(win => { + expect(win.onOpen).to.be.calledWith({ open: true }) + }) +}) diff --git a/cypress/integration/OrganisationUnitTree/children_as_child_nodes.feature b/cypress/integration/OrganisationUnitTree/children_as_child_nodes.feature new file mode 100644 index 0000000000..aaa2aab40f --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/children_as_child_nodes.feature @@ -0,0 +1,6 @@ +Feature: Children of a node are rendered as a child node + + Scenario: A unit has some children + Given an OrganisationUnitTree with children is rendered + And the node is open + Then its children are nodes inside the unit's node diff --git a/cypress/integration/OrganisationUnitTree/children_as_child_nodes/index.js b/cypress/integration/OrganisationUnitTree/children_as_child_nodes/index.js new file mode 100644 index 0000000000..d7d3672912 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/children_as_child_nodes/index.js @@ -0,0 +1,30 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an OrganisationUnitTree with children is rendered', () => { + cy.visitStory('OrganisationUnitTree', 'Closed with children') +}) + +Given('the node is open', () => { + cy.get( + '[data-test="dhis2-uiwidgets-orgunittree"] > [data-test="dhis2-uiwidgets-orgunittree-node"]' + ).as('rootUnit') + + cy.get('@rootUnit').openOrgUnitNode() +}) + +Then("its children are nodes inside the unit's node", () => { + cy.get('@rootUnit') + .find( + '> [data-test="dhis2-uiwidgets-orgunittree-node-content"] > [data-test="dhis2-uiwidgets-orgunittree-node-leaves"]' + ) + .children() + .then(children => console.log('children', children) || children) + .should('have.length', 3) + .and(children => + children.each((_, child) => { + const $child = Cypress.$(child) + const dataTest = $child.data('test') + expect(dataTest).to.equal('dhis2-uiwidgets-orgunittree-node') + }) + ) +}) diff --git a/cypress/integration/OrganisationUnitTree/displaying_loading_error.feature b/cypress/integration/OrganisationUnitTree/displaying_loading_error.feature new file mode 100644 index 0000000000..a1278b8541 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/displaying_loading_error.feature @@ -0,0 +1,24 @@ +Feature: When loading children fails a loading error should be shown + + Due to the asynchronous nature of the tree, loading children + can fail at any given time. An appropriate error message should + be displayed when this happens + + The error does not show automatically until a node's + children should be displayed. + This behavior can be changed to expand the node immediately + when the loading process fails. + + Scenario: Loading the children of the root unit fails and error should not be auto expanded + Given loading errors do not display automatically and loading A0000000001's children will fail + And the OrganisationUnitTree is closed + When the A0000000000 path is opened + Then no error message is shown + When the A0000000000 -> A0000000001 path is opened + Then an appropriate error message is shown + + Scenario: Loading the children of the root unit fails and error should be auto expanded + Given loading errors display automatically and loading A0000000001's children will fail + And the OrganisationUnitTree is closed + When the A0000000000 path is opened + Then an appropriate error message is shown diff --git a/cypress/integration/OrganisationUnitTree/displaying_loading_error/index.js b/cypress/integration/OrganisationUnitTree/displaying_loading_error/index.js new file mode 100644 index 0000000000..07d32bc827 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/displaying_loading_error/index.js @@ -0,0 +1,46 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + "loading errors do not display automatically and loading A0000000001's children will fail", + () => { + cy.visitStory('OrganisationUnitTree', 'A0000000001 loading error') + } +) + +Given( + "loading errors display automatically and loading A0000000001's children will fail", + () => { + cy.visitStory( + 'OrganisationUnitTree', + 'A0000000001 loading error autoexpand' + ) + } +) + +Given('the OrganisationUnitTree is closed', () => { + cy.get( + '[data-test="dhis2-uiwidgets-orgunittree"] > [data-test="dhis2-uiwidgets-orgunittree-node"]' + ) + .as('rootNode') + .shouldBeAClosedNode() +}) + +When('the A0000000000 path is opened', () => { + cy.getOrgUnitByLabel('Org Unit 1').openOrgUnitNode() +}) + +When('the A0000000000 -> A0000000001 path is opened', () => { + cy.getOrgUnitByLabel('Org Unit 2').openOrgUnitNode() +}) + +Then('no error message is shown', () => { + cy.getOrgUnitByLabel('Org Unit 2') + .find('[data-test="dhis2-uiwidgets-orgunittree-error"]') + .should('not.exist') +}) + +Then('an appropriate error message is shown', () => { + cy.getOrgUnitByLabel('Org Unit 2') + .find('[data-test="dhis2-uiwidgets-orgunittree-error"]') + .should('contain', 'Could not load children') +}) diff --git a/cypress/integration/OrganisationUnitTree/force_reload.feature b/cypress/integration/OrganisationUnitTree/force_reload.feature new file mode 100644 index 0000000000..292b69ed98 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/force_reload.feature @@ -0,0 +1,7 @@ +Feature: The whole organisation unit tree can be reloaded + + Scenario: The whole tree is reloaded while being opened + Given a OrganisationUnitTree with three levels is rendered + And the two parent levels are opened + When the tree is being force reloaded and the loading process has finished + Then all three levels are visible again diff --git a/cypress/integration/OrganisationUnitTree/force_reload/index.js b/cypress/integration/OrganisationUnitTree/force_reload/index.js new file mode 100644 index 0000000000..d11b71e013 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/force_reload/index.js @@ -0,0 +1,38 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a OrganisationUnitTree with three levels is rendered', () => { + cy.visitStory('OrganisationUnitTree', 'Force reloading') +}) + +Given('the two parent levels are opened', () => { + cy.getOrgUnitByLabel('Org Unit 1') + .as('rootNode') + .openOrgUnitNode() + + cy.getOrgUnitByLabel('Org Unit 2').openOrgUnitNode() + + cy.get('@rootNode') + .find( + '[data-test="dhis2-uiwidgets-orgunittree-node"] [data-test="dhis2-uiwidgets-orgunittree-node"]' + ) + .should('exist') +}) + +When( + 'the tree is being force reloaded and the loading process has finished', + () => { + cy.get('[data-test="reload-all"]').click() + } +) + +Then('all three levels are visible again', () => { + cy.get('@rootNode') + .find('[data-test="dhis2-uiwidgets-orgunittree-node"]') + .should('exist') + + cy.get('@rootNode') + .find( + '[data-test="dhis2-uiwidgets-orgunittree-node"] [data-test="dhis2-uiwidgets-orgunittree-node"]' + ) + .should('exist') +}) diff --git a/cypress/integration/OrganisationUnitTree/highlight.feature b/cypress/integration/OrganisationUnitTree/highlight.feature new file mode 100644 index 0000000000..1251a48a27 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/highlight.feature @@ -0,0 +1,5 @@ +Feature: Units can be highlighted + + Scenario: The root unit is highlighted + Given a OrganisationUnitTree with a highlighted root unit is rendered + Then root unit has the highlighted styles diff --git a/cypress/integration/OrganisationUnitTree/highlight/index.js b/cypress/integration/OrganisationUnitTree/highlight/index.js new file mode 100644 index 0000000000..fa88a894ed --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/highlight/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a OrganisationUnitTree with a highlighted root unit is rendered', () => { + cy.visitStory('OrganisationUnitTree', 'Root highlighted') +}) + +Then('root unit has the highlighted styles', () => { + cy.getOrgUnitByLabel('Org Unit 1') + .find('.highlighted') + .should('exist') +}) diff --git a/cypress/integration/OrganisationUnitTree/initially_expanded.feature b/cypress/integration/OrganisationUnitTree/initially_expanded.feature new file mode 100644 index 0000000000..657ccc497c --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/initially_expanded.feature @@ -0,0 +1,11 @@ +Feature: When provided some paths of the OrganisationUnitTree are expanded initially + + Scenario: No initially expanded paths are provided + Given a OrganisationUnitTree with children and no paths in the initiallyExpanded prop is rendered + Then the root unit is closed + + # The given step is really long.. but I don't know how to split it while being able to test/prepare + # that with cypress + Scenario: The first unit on the second level is provided as initially expanded path + Given a OrganisationUnitTree with children and the path of the first unit on the second level in the initiallyExpanded prop is rendered + Then the root unit is opened diff --git a/cypress/integration/OrganisationUnitTree/initially_expanded/index.js b/cypress/integration/OrganisationUnitTree/initially_expanded/index.js new file mode 100644 index 0000000000..35800c27bb --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/initially_expanded/index.js @@ -0,0 +1,23 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a OrganisationUnitTree with children and no paths in the initiallyExpanded prop is rendered', + () => { + cy.visitStory('OrganisationUnitTree', 'No initially expanded paths') + } +) + +Given( + 'a OrganisationUnitTree with children and the path of the first unit on the second level in the initiallyExpanded prop is rendered', + () => { + cy.visitStory('OrganisationUnitTree', 'Initially expanded paths') + } +) + +Then('the root unit is closed', () => { + cy.getOrgUnitByLabel('Org Unit 1').shouldBeAClosedNode() +}) + +Then('the root unit is opened', () => { + cy.getOrgUnitByLabel('Org Unit 1').shouldBeAnOrgUnitNode() +}) diff --git a/cypress/integration/OrganisationUnitTree/loading_state.feature b/cypress/integration/OrganisationUnitTree/loading_state.feature new file mode 100644 index 0000000000..eaf2c644fc --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/loading_state.feature @@ -0,0 +1,7 @@ +Feature: When a unit is loading its children then there is a loading indicator + + Scenario: The first second-level unit is loading its children + Given a OrganisationUnitTree with two levels is rendered + And the root level is closed + When the root level is opened + Then there is a loading indicator rendered on the first child of the second level diff --git a/cypress/integration/OrganisationUnitTree/loading_state/index.js b/cypress/integration/OrganisationUnitTree/loading_state/index.js new file mode 100644 index 0000000000..e0d66275e9 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/loading_state/index.js @@ -0,0 +1,28 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a OrganisationUnitTree with two levels is rendered', () => { + cy.visitStory('OrganisationUnitTree', 'A0000000001 loading') +}) + +Given('the root level is closed', () => { + cy.getOrgUnitByLabel('Org Unit 1') + .as('rootUnit') + .shouldBeAClosedNode() +}) + +When('the root level is opened', () => { + cy.get('@rootUnit').openOrgUnitNode() +}) + +Then( + 'there is a loading indicator rendered on the first child of the second level', + () => { + cy.getOrgUnitByLabel('Org Unit 2') + .find('[data-test="dhis2-uicore-circularloader"]') + .should('exist') + + cy.getOrgUnitByLabel('Org Unit 2') + .find('[data-test="dhis2-uiwidgets-orgunittree-node-leaves"]:empty') + .should('exist') + } +) diff --git a/cypress/integration/OrganisationUnitTree/multi_selection.feature b/cypress/integration/OrganisationUnitTree/multi_selection.feature new file mode 100644 index 0000000000..78ddbcb82d --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/multi_selection.feature @@ -0,0 +1,31 @@ +Feature: When not limited any amount of units can be selected + + Scenario: The user selects a unit when no other unit is selected + Given an OrganisationUnitTree with two levels is rendered + And no unit is selected + When the user selects a unit + Then a unit is selected + + Scenario: The user selects a unit when a parent unit is not selected + Given an OrganisationUnitTree with two levels is rendered + And the root level unit is opened + When the user selects the first unit on the second level + Then the unit on the second level is selected + Then the unit on the first level is marked as selected intermediately + + Scenario: The user selects a unit which is selected intermediately + Given an OrganisationUnitTree with two levels is rendered + And the root level unit is opened + And the first unit on the second level is selected + When the user selects the root level + Then the root unit is marked as selected + + Scenario: The user selects a unit when another unit on the same level is selected + Given an OrganisationUnitTree with two levels is rendered + And the root level unit is opened + And the first unit on the second level unit is opened + And the second level has two units + And the first unit on the second level is selected + When the user selects the second unit on the second level + Then the first unit is selected + Then the second unit is selected diff --git a/cypress/integration/OrganisationUnitTree/multi_selection/index.js b/cypress/integration/OrganisationUnitTree/multi_selection/index.js new file mode 100644 index 0000000000..7a7629e6ba --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/multi_selection/index.js @@ -0,0 +1,94 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an OrganisationUnitTree with two levels is rendered', () => { + cy.visitStory('OrganisationUnitTree', 'Multiple selection') +}) + +Given('no unit is selected', () => { + cy.window().then(win => { + expect(win.selection).to.eql([]) + }) +}) + +Given('the root level unit is opened', () => { + cy.getOrgUnitByLabel('Org Unit 1').openOrgUnitNode(true) +}) + +Given('the first unit on the second level is selected', () => { + cy.getOrgUnitByLabel('Org Unit 2').toggleOrgUnitNodeSelection(true) +}) + +Given('the first unit on the second level unit is opened', () => { + cy.getOrgUnitByLabel('Org Unit 2').openOrgUnitNode(true) +}) + +Given('the second level has two units', () => { + cy.getOrgUnitByLabel('Org Unit 2') + .find('[data-test="dhis2-uiwidgets-orgunittree-node-leaves"]') + .first() + .children() + .filter((index, child) => + Cypress.$(child).is( + '[data-test="dhis2-uiwidgets-orgunittree-node"]' + ) + ) + .should('have.length', 2) +}) + +When('the user selects a unit', () => { + cy.getOrgUnitByLabel('Org Unit 1').toggleOrgUnitNodeSelection(true) +}) + +When('the user selects the first unit on the second level', () => { + cy.getOrgUnitByLabel('Org Unit 2').toggleOrgUnitNodeSelection(true) +}) + +When('the user selects the root level', () => { + cy.getOrgUnitByLabel('Org Unit 1').toggleOrgUnitNodeSelection(true) +}) + +When('the user selects the second unit on the second level', () => { + cy.getOrgUnitByLabel('Org Unit 3').toggleOrgUnitNodeSelection(true) +}) + +Then('a unit is selected', () => { + cy.window().then(win => { + expect(win.selection).to.eql(['/A0000000000']) + }) +}) + +Then('the unit on the second level is selected', () => { + cy.window().then(win => { + expect(win.selection).to.eql(['/A0000000000/A0000000001']) + }) +}) + +Then('the unit on the first level is marked as selected intermediately', () => { + cy.getOrgUnitByLabel('Org Unit 1') + .find( + '[data-test="dhis2-uiwidgets-orgunittree-node-label"] [data-test="dhis2-uicore-checkbox"] input' + ) + .then($input => { + expect($input[0].indeterminate).to.be.true + }) +}) + +Then('the root unit is marked as selected', () => { + cy.window().then(win => { + expect(win.selection.includes('/A0000000000')).to.be.true + }) +}) + +Then('the first unit is selected', () => { + cy.window().then(win => { + expect(win.selection.includes('/A0000000000/A0000000001')).to.be.true + expect(win.selection).to.have.length(2) + }) +}) + +Then('the second unit is selected', () => { + cy.window().then(win => { + expect(win.selection.includes('/A0000000000/A0000000002')).to.be.true + expect(win.selection).to.have.length(2) + }) +}) diff --git a/cypress/integration/OrganisationUnitTree/no_selection.feature b/cypress/integration/OrganisationUnitTree/no_selection.feature new file mode 100644 index 0000000000..79bd449748 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/no_selection.feature @@ -0,0 +1,13 @@ +Feature: Selection can be disabled entirely in the OrganisationUnitTree + + Scenario: The user clicks on a level of the disabled OrganisationUnitTree with the second level collapsed + Given a disabled, collapsed OrganisationUnitTree with two levels is rendered + When the root node is clicked + Then the root node remains unselected + But the second level is expanded + + Scenario: The user clicks on a level of the disabled OrganisationUnitTree with the second level expanded + Given a disabled OrganisationUnitTree with two levels is rendered with the second level is expanded + When the root node is clicked + Then the root node remains unselected + But the second level is collapsed diff --git a/cypress/integration/OrganisationUnitTree/no_selection/index.js b/cypress/integration/OrganisationUnitTree/no_selection/index.js new file mode 100644 index 0000000000..e932e37a2e --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/no_selection/index.js @@ -0,0 +1,43 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a disabled, collapsed OrganisationUnitTree with two levels is rendered', + () => { + cy.visitStory('OrganisationUnitTree', 'No selection closed') + } +) + +Given( + 'a disabled OrganisationUnitTree with two levels is rendered with the second level is expanded', + () => { + cy.visitStory('OrganisationUnitTree', 'No selection root opened') + } +) + +When('the root node is clicked', () => { + cy.getOrgUnitByLabel('Org Unit 1') + .find('[data-test="dhis2-uiwidgets-orgunittree-node-label"]') + .first() + .trigger('click') +}) + +Then('the root node remains unselected', () => { + cy.getOrgUnitByLabel('Org Unit 1') + .find('[data-test="dhis2-uiwidgets-orgunittree-node-label"]') + .first() + .as('rootLabel') + .find('.checked') + .should('not.exist') + + cy.get('@rootLabel') + .find('input') + .should('not.exist') +}) + +Then('the second level is expanded', () => { + cy.getOrgUnitByLabel('Org Unit 1').shouldBeAnOpenNode() +}) + +Then('the second level is collapsed', () => { + cy.getOrgUnitByLabel('Org Unit 1').shouldBeAClosedNode() +}) diff --git a/cypress/integration/OrganisationUnitTree/path_based_filtering.feature b/cypress/integration/OrganisationUnitTree/path_based_filtering.feature new file mode 100644 index 0000000000..2e8a1f8382 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/path_based_filtering.feature @@ -0,0 +1,24 @@ +Feature: The OrganisationUnitTree can be filtered by paths + + Scenario: The org unit tree is filtered by a three-level-deep path + Given a unfiltered OrganisationUnitTree with a depth of 3 levels is rendered + And the second level contains two nodes + And all parent levels are open + And a filter containing the first child of the first second level is provided + Then the root level is visible + And the first node on the second level is visible + And the first child node of the first node on the second level is visible + And all other nodes are not rendered + + Scenario: The org unit tree is filtered by a three-level- and a two-level-deep path + Given a filtered OrganisationUnitTree with a depth of 3 levels is rendered + And the second level contains two nodes + And the second level nodes each have a child node + And all parent levels are open + And a filter containing the first child of the second level is provided + And a filter containing the second child of the first level is provided + Then the root level is visible + And the first node on the second level is visible + And the first child node of the first node on the second level is visible + And the second node on the first level is visible + And all other nodes are not rendered diff --git a/cypress/integration/OrganisationUnitTree/path_based_filtering/index.js b/cypress/integration/OrganisationUnitTree/path_based_filtering/index.js new file mode 100644 index 0000000000..f089719720 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/path_based_filtering/index.js @@ -0,0 +1,95 @@ +import { Before, Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Before(() => { + cy.wrap([]).as('displayedUnits') +}) + +const addDisplayedUnit = id => { + cy.get('@displayedUnits').then(displayedUnits => { + cy.wrap([...displayedUnits, id]).as('displayedUnits') + }) +} + +Given( + 'a unfiltered OrganisationUnitTree with a depth of 3 levels is rendered', + () => { + cy.visitStory('OrganisationUnitTree', 'Filtered by 3-level-path') + } +) + +Given( + 'a filtered OrganisationUnitTree with a depth of 3 levels is rendered', + () => { + cy.visitStory( + 'OrganisationUnitTree', + 'Filtered by 3-level-path and 2-level-path' + ) + } +) + +/** + * left empty intentionally + * ======================== + * Step is necessary to make the feature file understandable + * Required states are prepared by the story + */ +Given('the second level contains two nodes', () => {}) +Given('all parent levels are open', () => {}) +Given( + 'a filter containing the first child of the first second level is provided', + () => {} +) +Given('the second level nodes each have a child node', () => {}) +Given( + 'a filter containing the first child of the second level is provided', + () => {} +) +Given( + 'a filter containing the second child of the first level is provided', + () => {} +) + +Then('the root level is visible', () => { + cy.getOrgUnitByLabel('Org Unit 1').should('exist') + + addDisplayedUnit('A0000000000') +}) + +Then('the first node on the second level is visible', () => { + cy.getOrgUnitByLabel('Org Unit 2').should('exist') + + addDisplayedUnit('A0000000001') +}) + +Then( + 'the first child node of the first node on the second level is visible', + () => { + cy.getOrgUnitByLabel('Org Unit 4').should('exist') + + addDisplayedUnit('A0000000003') + } +) + +Then('the second node on the first level is visible', () => { + cy.getOrgUnitByLabel('Org Unit 3').should('exist') + + addDisplayedUnit('A0000000002') +}) + +Then('all other nodes are not rendered', async () => { + cy.get('@displayedUnits').then(displayedUnits => { + cy.window().then(win => { + const excludedUnitNumbers = Object.keys(win.dataProviderData) + .map(key => key.replace('organisationUnits/', '')) + .filter(unit => !displayedUnits.includes(unit)) + .map(id => parseInt(id.replace(/^.*(\d)$/, '$1'), 10) + 1) + + excludedUnitNumbers.forEach(number => { + const label = `Org Unit ${number}` + cy.get( + `[data-test="dhis2-uiwidgets-orgunittree-node-label"]:contains("${label}")` + ).should('not.exist') + }) + }) + }) +}) diff --git a/cypress/integration/OrganisationUnitTree/single_selection.feature b/cypress/integration/OrganisationUnitTree/single_selection.feature new file mode 100644 index 0000000000..7ac22752cd --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/single_selection.feature @@ -0,0 +1,20 @@ +Feature: When specified only one unit can be selected + + Scenario: The user selects a unit when no other unit is selected + Given an OrganisationUnitTree with two nodes is rendered + And no unit is selected + When the user selects the first unit + Then the first unit is selected + + Scenario: The user select a unit when another unit is selected + Given an OrganisationUnitTree with two nodes is rendered + And the first unit has been selected + When the user selects the second unit + Then the first unit is not selected + Then the second unit is selected + + Scenario: The user deselects a unit + Given an OrganisationUnitTree with two nodes is rendered + And the first unit has been selected + When the user deselects the first unit + Then no unit is selected diff --git a/cypress/integration/OrganisationUnitTree/single_selection/index.js b/cypress/integration/OrganisationUnitTree/single_selection/index.js new file mode 100644 index 0000000000..33524077de --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/single_selection/index.js @@ -0,0 +1,45 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an OrganisationUnitTree with two nodes is rendered', () => { + cy.visitStory('OrganisationUnitTree', 'Single selection') +}) + +Given('no unit is selected', () => { + cy.get('.checked').should('not.exist') +}) + +Given('the first unit has been selected', () => { + cy.getOrgUnitByLabel('Org Unit 1').toggleOrgUnitNodeSelection(true, true) +}) + +When('the user selects the first unit', () => { + cy.getOrgUnitByLabel('Org Unit 1').toggleOrgUnitNodeSelection(true, true) +}) + +When('the user selects the second unit', () => { + cy.getOrgUnitByLabel('Org Unit 2').toggleOrgUnitNodeSelection(true, true) +}) + +When('the user deselects the first unit', () => { + cy.getOrgUnitByLabel('Org Unit 1').toggleOrgUnitNodeSelection(false, true) +}) + +Then('the first unit is selected', () => { + cy.getOrgUnitByLabel('Org Unit 1').shouldBeASelectedOrgUnitNode(true) +}) + +Then('the first unit is not selected', () => { + cy.getOrgUnitByLabel('Org Unit 1').shouldNotBeASelectedOrgUnitNode(true) +}) + +Then('the second unit is selected', () => { + cy.getOrgUnitByLabel('Org Unit 2').shouldBeASelectedOrgUnitNode(true) +}) + +Then('no unit is selected', () => { + cy.get('[data-test="dhis2-uiwidgets-orgunittree-node"]').then($nodes => { + $nodes.each((index, node) => { + cy.wrap(Cypress.$(node)).shouldNotBeASelectedOrgUnitNode() + }) + }) +}) diff --git a/cypress/integration/OrganisationUnitTree/tree_api.feature b/cypress/integration/OrganisationUnitTree/tree_api.feature new file mode 100644 index 0000000000..26906f7106 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/tree_api.feature @@ -0,0 +1,37 @@ +Feature: The OrganisationUnitTree offers props for its events + + Scenario: The user selects a node + Given an OrganisationUnitTree is rendered + When a node gets selected + Then the onChange callback gets called + And the payload includes the path of the selected node + And the payload includes checked which is set to "true" + And the payload includes all selected nodes + + Scenario: The user deselects a node + Given an OrganisationUnitTree is rendered + And a node has been selected + When a node gets deselected + Then the onChange callback gets called + And the payload includes the path of the selected node + And the payload includes checked which is set to "false" + + Scenario: The user expands a node with children + Given a node with children is rendered + When the node is expanded + Then the onExpand callback gets called + And the payload includes the path of the expanded node + + Scenario: The user collapses a node with children + Given a node with children is rendered + And the node has been expanded + When a node is collapsed + Then the onCollapse callback gets called + And the payload includes the path of the collapsed node + + Scenario: Children of a node are done being loaded + Given a node with children is rendered + But the children haven't been loaded yet + When the children have been loaded + Then the onChildrenLoaded callback gets called + And the payload contains the loaded children's data diff --git a/cypress/integration/OrganisationUnitTree/tree_api/index.js b/cypress/integration/OrganisationUnitTree/tree_api/index.js new file mode 100644 index 0000000000..f023c809c7 --- /dev/null +++ b/cypress/integration/OrganisationUnitTree/tree_api/index.js @@ -0,0 +1,121 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +const expectStubPayloadToEqual = (stub, prop, expected) => { + const calls = stub.getCalls() + const { args } = calls[calls.length - 1] + const [payload] = args + expect(payload[prop]).to.eql(expected) +} + +Given('an OrganisationUnitTree is rendered', () => { + cy.visitStory('OrganisationUnitTree', 'Events') + cy.getOrgUnitByLabel('Org Unit 1').shouldBeDoneLoading() +}) + +Given('a node has been selected', () => { + cy.getOrgUnitByLabel('Org Unit 1').toggleOrgUnitNodeSelection(true) +}) + +Given('a node with children is rendered', () => { + cy.visitStory('OrganisationUnitTree', 'Events') +}) + +Given('the node has been expanded', () => { + cy.getOrgUnitByLabel('Org Unit 1').openOrgUnitNode() +}) + +Given("the children haven't been loaded yet", () => { + cy.window().then(win => { + expect(win.onExpand).not.to.be.called + }) +}) + +When('a node gets selected', () => { + cy.getOrgUnitByLabel('Org Unit 1').toggleOrgUnitNodeSelection(true) +}) + +When('a node gets deselected', () => { + cy.getOrgUnitByLabel('Org Unit 1').toggleOrgUnitNodeSelection(false) +}) + +When('the node is expanded', () => { + cy.getOrgUnitByLabel('Org Unit 1').openOrgUnitNode() +}) + +When('a node is collapsed', () => { + cy.getOrgUnitByLabel('Org Unit 1').closeOrgUnitNode() +}) + +When('the children have been loaded', () => { + cy.getOrgUnitByLabel('Org Unit 1').toggleOrgUnitNode(true) +}) + +Then('the onChange callback gets called', () => { + cy.window().then(win => { + expect(win.onChange).to.be.called + }) +}) + +Then('the payload includes the path of the selected node', () => { + cy.window().then(win => { + expectStubPayloadToEqual(win.onChange, 'path', '/A0000000000') + }) +}) + +Then('the payload includes checked which is set to "true"', () => { + cy.window().then(win => { + expectStubPayloadToEqual(win.onChange, 'checked', true) + }) +}) + +Then('the payload includes all selected nodes', () => { + cy.window().then(win => { + expectStubPayloadToEqual(win.onChange, 'selected', ['/A0000000000']) + }) +}) + +Then('the payload includes checked which is set to "false"', () => { + cy.window().then(win => { + expectStubPayloadToEqual(win.onChange, 'checked', false) + }) +}) + +Then('the onExpand callback gets called', () => { + cy.window().then(win => { + expect(win.onExpand).to.be.called + }) +}) + +Then('the payload includes the path of the expanded node', () => { + cy.window().then(win => { + expectStubPayloadToEqual(win.onExpand, 'path', '/A0000000000') + }) +}) + +Then('the onCollapse callback gets called', () => { + cy.window().then(win => { + expect(win.onCollapse).to.be.called + }) +}) + +Then('the payload includes the path of the collapsed node', () => { + cy.window().then(win => { + expectStubPayloadToEqual(win.onCollapse, 'path', '/A0000000000') + }) +}) + +Then('the onChildrenLoaded callback gets called', () => { + cy.window().then(win => { + expect(win.onChildrenLoaded).to.be.called + }) +}) + +Then("the payload contains the loaded children's data", () => { + cy.window().then(win => { + const calls = win.onChildrenLoaded.getCalls() + const { args } = calls[calls.length - 1] + expect(args[0].A0000000001).to.deep.eql( + win.dataProviderData['organisationUnits/A0000000001'] + ) + }) +}) diff --git a/cypress/integration/Popover/conditional_arrow/index.js b/cypress/integration/Popover/conditional_arrow/index.js new file mode 100644 index 0000000000..8fe26d9468 --- /dev/null +++ b/cypress/integration/Popover/conditional_arrow/index.js @@ -0,0 +1,14 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default Popper is rendered with arrow set to true', () => { + cy.visitStory('Popover', 'Default') +}) +Given('a Popover is rendered with the arrow prop set to false', () => { + cy.visitStory('Popover', 'No Arrow') +}) +Then('there is an arrow element in the Popover', () => { + cy.get('[data-popper-arrow="true"]').should('exist') +}) +Then('there is no arrow element in the Popover', () => { + cy.get('[data-popper-arrow="true"]').should('not.exist') +}) diff --git a/cypress/integration/Popover/position/index.js b/cypress/integration/Popover/position/index.js new file mode 100644 index 0000000000..e60d43c37e --- /dev/null +++ b/cypress/integration/Popover/position/index.js @@ -0,0 +1,116 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' +import { ARROW_SIZE } from '../../../../src/Popover/Arrow' + +// Stories +Given( + 'there is sufficient space to place the Popover above the reference element', + () => { + cy.visitStory('Popover', 'Default') + } +) + +Given( + 'there is not enough space between the reference element top and the body top to fit the Popover', + () => { + cy.visitStory('Popover', 'Flipped') + } +) + +Given( + 'there is very little space between the top of the reference element and the top of the body', + () => { + cy.visitStory('Popover', 'Hidden Arrow') + } +) + +// Window height manipulation to control the space below the reference element +Given( + 'there is sufficient space between the reference element bottom and the body bottom to fit the Popover', + () => { + cy.viewport(1000, 660) + } +) + +Given( + 'there is not enough space between the reference element bottom and the body bottom to fit the Popover', + () => { + cy.viewport(1000, 325) + } +) + +Given( + 'there is not enough space between the top of the reference element and the top of the Popover to show the arrow', + () => { + cy.viewport(1000, 400) + } +) + +// Assertions +Given( + 'there is sufficient space between the bottom of the reference element and the bottom of the Popover to show the arrow', + () => { + getRefAndPopoverPositions().then(([refPos, popoverPos]) => { + expect(refPos.bottom).to.be.greaterThan( + popoverPos.bottom + ARROW_SIZE + ) + }) + } +) + +Then('the arrow is hiding', () => { + cy.get('[data-test="dhis2-uicore-popover-popper"]').should( + 'have.attr', + 'data-popper-arrow-displaced' + ) +}) + +Then('the arrow is showing', () => { + cy.get('[data-test="dhis2-uicore-popover-popper"]').should( + 'not.have.attr', + 'data-popper-arrow-displaced' + ) +}) + +Then( + 'the horizontal center of the popover is aligned with the horizontal center of the reference element', + () => { + getRefAndPopoverPositions().then(([refPos, popoverPos]) => { + const refCenter = refPos.left + refPos.width / 2 + const popoverCenter = popoverPos.left + popoverPos.width / 2 + expect(refCenter).to.equal(popoverCenter) + }) + } +) + +Then('the popover is placed above the reference element', () => { + getRefAndPopoverPositions().then(([refPos, popoverPos]) => { + expect(refPos.top).to.be.greaterThan(popoverPos.bottom) + }) +}) + +Then('the popover is placed below the reference element', () => { + getRefAndPopoverPositions().then(([refPos, popoverPos]) => { + expect(popoverPos.top).to.be.greaterThan(refPos.bottom) + }) +}) + +Then('the popover is placed op top of the reference element', () => { + getRefAndPopoverPositions().then(([refPos, popoverPos]) => { + expect(popoverPos.bottom).to.be.greaterThan(refPos.top) + expect(refPos.top).to.be.greaterThan(popoverPos.top) + }) +}) + +Then('there is some space between the anchor and the popover', () => { + getRefAndPopoverPositions().then(([refPos, popoverPos]) => { + expect(popoverPos.bottom + ARROW_SIZE).to.equal(refPos.top) + }) +}) + +// helper +function getRefAndPopoverPositions() { + return cy.getPositionsBySelectors( + '[data-test="reference-element"]', + '[data-test="dhis2-uicore-popover-popper"]' + ) +} diff --git a/cypress/integration/Popper/Positions.feature b/cypress/integration/Popper/Positions.feature new file mode 100644 index 0000000000..2680a77667 --- /dev/null +++ b/cypress/integration/Popper/Positions.feature @@ -0,0 +1,63 @@ +Feature: Generic position options + + # the definition of adjacent below: having a common endpoint or border + + Scenario: Top position + Given the Popper is rendered with placement top + Then the bottom of the popper is adjacent to the top of the reference element + And it is horizontally center aligned with the reference element + + Scenario: Top-start position + Given the Popper is rendered with placement top-start + Then the bottom of the popper is adjacent to the top of the reference element + And it is horizontally left aligned with the reference element + + Scenario: Top-end position + Given the Popper is rendered with placement top-end + Then the bottom of the popper is adjacent to the top of the reference element + And it is horizontally right aligned with the reference element + + Scenario: Right position + Given the Popper is rendered with placement right + Then the left of the popper is adjacent to the right of the reference element + And it is vertically center aligned with the reference element + + Scenario: Right-start position + Given the Popper is rendered with placement right-start + Then the left of the popper is adjacent to the right of the reference element + And it is vertically top aligned with the reference element + + Scenario: Right-end position + Given the Popper is rendered with placement right-end + Then the left of the popper is adjacent to the right of the reference element + And it is vertically bottom aligned with the reference element + + Scenario: Bottom position + Given the Popper is rendered with placement bottom + Then the top of the popper is adjacent to the bottom of the reference element + And it is horizontally center aligned with the reference element + + Scenario: Bottom-start position + Given the Popper is rendered with placement bottom-start + Then the top of the popper is adjacent to the bottom of the reference element + And it is horizontally left aligned with the reference element + + Scenario: Bottom-end position + Given the Popper is rendered with placement bottom-end + Then the top of the popper is adjacent to the bottom of the reference element + And it is horizontally right aligned with the reference element + + Scenario: Left position + Given the Popper is rendered with placement left + Then the right of the popper is adjacent to the left of the reference element + And it is vertically center aligned with the reference element + + Scenario: Left-start position + Given the Popper is rendered with placement left-start + Then the right of the popper is adjacent to the left of the reference element + And it is vertically top aligned with the reference element + + Scenario: Left-end position + Given the Popper is rendered with placement left-end + Then the right of the popper is adjacent to the left of the reference element + And it is vertically bottom aligned with the reference element diff --git a/cypress/integration/Popper/Positions/index.js b/cypress/integration/Popper/Positions/index.js new file mode 100644 index 0000000000..e0c27cebc8 --- /dev/null +++ b/cypress/integration/Popper/Positions/index.js @@ -0,0 +1,131 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +// Visit stories with different placements +Given('the Popper is rendered with placement top', () => { + cy.visitStory('Popper', 'top') +}) +Given('the Popper is rendered with placement top-start', () => { + cy.visitStory('Popper', 'top-start') +}) +Given('the Popper is rendered with placement top-end', () => { + cy.visitStory('Popper', 'top-end') +}) +Given('the Popper is rendered with placement right', () => { + cy.visitStory('Popper', 'right') +}) +Given('the Popper is rendered with placement right-start', () => { + cy.visitStory('Popper', 'right-start') +}) +Given('the Popper is rendered with placement right-end', () => { + cy.visitStory('Popper', 'right-end') +}) +Given('the Popper is rendered with placement bottom', () => { + cy.visitStory('Popper', 'bottom') +}) +Given('the Popper is rendered with placement bottom-start', () => { + cy.visitStory('Popper', 'bottom-start') +}) +Given('the Popper is rendered with placement bottom-end', () => { + cy.visitStory('Popper', 'bottom-end') +}) +Given('the Popper is rendered with placement left', () => { + cy.visitStory('Popper', 'left') +}) +Given('the Popper is rendered with placement left-start', () => { + cy.visitStory('Popper', 'left-start') +}) +Given('the Popper is rendered with placement left-end', () => { + cy.visitStory('Popper', 'left-end') +}) + +// Directional assertions +// top +Then( + 'the bottom of the popper is adjacent to the top of the reference element', + () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + expect(refPos.top).to.equal(popperPos.bottom) + }) + } +) +// right +Then( + 'the left of the popper is adjacent to the right of the reference element', + () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + expect(refPos.right).to.equal(popperPos.left) + }) + } +) +// bottom +Then( + 'the top of the popper is adjacent to the bottom of the reference element', + () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + expect(refPos.bottom).to.equal(popperPos.top) + }) + } +) +// left +Then( + 'the right of the popper is adjacent to the left of the reference element', + () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + expect(refPos.left).to.equal(popperPos.right) + }) + } +) + +// Horizontal alignments +// *-start +Then('it is horizontally left aligned with the reference element', () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + expect(refPos.left).to.equal(popperPos.left) + }) +}) +// * (no suffix) +Then('it is horizontally center aligned with the reference element', () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + const refCenterX = refPos.left + refPos.width / 2 + const popperCenterX = popperPos.left + popperPos.width / 2 + + expect(refCenterX).to.equal(popperCenterX) + }) +}) +// *-end +Then('it is horizontally right aligned with the reference element', () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + expect(refPos.right).to.equal(popperPos.right) + }) +}) + +// Vertical alignments +// *-start +Then('it is vertically top aligned with the reference element', () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + expect(refPos.top).to.equal(popperPos.top) + }) +}) +// * (no suffix) +Then('it is vertically center aligned with the reference element', () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + const refCenterY = refPos.top + refPos.height / 2 + const popperCenterY = popperPos.top + popperPos.height / 2 + + expect(refCenterY).to.equal(popperCenterY) + }) +}) +// *-end +Then('it is vertically bottom aligned with the reference element', () => { + getRefAndPopperPositions().then(([refPos, popperPos]) => { + expect(refPos.bottom).to.equal(popperPos.bottom) + }) +}) + +// helper +function getRefAndPopperPositions() { + return cy.getPositionsBySelectors( + '.reference-element', + '[data-test="dhis2-uicore-popper"]' + ) +} diff --git a/cypress/integration/Radio/Accepts_initial_focus.feature b/cypress/integration/Radio/Accepts_initial_focus.feature new file mode 100644 index 0000000000..eaf5c09158 --- /dev/null +++ b/cypress/integration/Radio/Accepts_initial_focus.feature @@ -0,0 +1,5 @@ +Feature: Focusing the Radio on mount + + Scenario: The Radio renders with focus + Given a Radio with initialFocus is rendered + Then the Radio is focused diff --git a/cypress/integration/Radio/Accepts_initial_focus/index.js b/cypress/integration/Radio/Accepts_initial_focus/index.js new file mode 100644 index 0000000000..9f447b69e3 --- /dev/null +++ b/cypress/integration/Radio/Accepts_initial_focus/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Radio with initialFocus is rendered', () => { + cy.visitStory('Radio', 'With initialFocus') +}) + +Then('the Radio is focused', () => { + cy.focused() + .parent('[data-test="dhis2-uicore-radio"]') + .should('exist') +}) diff --git a/cypress/integration/Radio/Accepts_label.feature b/cypress/integration/Radio/Accepts_label.feature new file mode 100644 index 0000000000..05171598d8 --- /dev/null +++ b/cypress/integration/Radio/Accepts_label.feature @@ -0,0 +1,5 @@ +Feature: The Radio shows a label + + Scenario: The Radio has a label + Given a Radio with a label is rendered + Then the label is shown diff --git a/cypress/integration/Radio/Accepts_label/index.js b/cypress/integration/Radio/Accepts_label/index.js new file mode 100644 index 0000000000..b092f46bc0 --- /dev/null +++ b/cypress/integration/Radio/Accepts_label/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Radio with a label is rendered', () => { + cy.visitStory('Radio', 'With label') +}) + +Then('the label is shown', () => { + cy.get('[data-test="dhis2-uicore-radio"]') + .contains('The label') + .should('be.visible') +}) diff --git a/cypress/integration/Radio/Can_be_blurred.feature b/cypress/integration/Radio/Can_be_blurred.feature new file mode 100644 index 0000000000..8a8f9bda43 --- /dev/null +++ b/cypress/integration/Radio/Can_be_blurred.feature @@ -0,0 +1,6 @@ +Feature: The Radio has an onBlur api + + Scenario: The user blurs the Radio + Given a Radio with initialFocus and onBlur handler is rendered + When the Radio is blurred + Then the onBlur handler is called diff --git a/cypress/integration/Radio/Can_be_blurred/index.js b/cypress/integration/Radio/Can_be_blurred/index.js new file mode 100644 index 0000000000..b586edfa9a --- /dev/null +++ b/cypress/integration/Radio/Can_be_blurred/index.js @@ -0,0 +1,19 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Radio with initialFocus and onBlur handler is rendered', () => { + cy.visitStory('Radio', 'With initialFocus and onBlur') +}) + +When('the Radio is blurred', () => { + cy.get('[data-test="dhis2-uicore-radio"] input').blur() +}) + +Then('the onBlur handler is called', () => { + cy.window().then(win => { + expect(win.onBlur).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/Radio/Can_be_changed.feature b/cypress/integration/Radio/Can_be_changed.feature new file mode 100644 index 0000000000..b23f575f81 --- /dev/null +++ b/cypress/integration/Radio/Can_be_changed.feature @@ -0,0 +1,6 @@ +Feature: The Radio has an onChange api + + Scenario: The user checks the Radio + Given a Radio with onChange handler is rendered + When the Radio is checked + Then the onChange handler is called diff --git a/cypress/integration/Radio/Can_be_changed/index.js b/cypress/integration/Radio/Can_be_changed/index.js new file mode 100644 index 0000000000..5115ad7904 --- /dev/null +++ b/cypress/integration/Radio/Can_be_changed/index.js @@ -0,0 +1,19 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Radio with onChange handler is rendered', () => { + cy.visitStory('Radio', 'With onChange') +}) + +When('the Radio is checked', () => { + cy.get('[data-test="dhis2-uicore-radio"]').click() +}) + +Then('the onChange handler is called', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/Radio/Can_be_disabled.feature b/cypress/integration/Radio/Can_be_disabled.feature new file mode 100644 index 0000000000..e5784cba6e --- /dev/null +++ b/cypress/integration/Radio/Can_be_disabled.feature @@ -0,0 +1,6 @@ +Feature: The Radio can be disabled + + Scenario: The user clicks a disabled Radio + Given a disabled Radio is rendered + When the user clicks the Radio + Then the Radio is not focused diff --git a/cypress/integration/Radio/Can_be_disabled/index.js b/cypress/integration/Radio/Can_be_disabled/index.js new file mode 100644 index 0000000000..823b43f8b4 --- /dev/null +++ b/cypress/integration/Radio/Can_be_disabled/index.js @@ -0,0 +1,13 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a disabled Radio is rendered', () => { + cy.visitStory('Radio', 'With disabled') +}) + +When('the user clicks the Radio', () => { + cy.get('[data-test="dhis2-uicore-radio"] input').click({ force: true }) +}) + +Then('the Radio is not focused', () => { + cy.focused().should('not.exist') +}) diff --git a/cypress/integration/Radio/Can_be_focused.feature b/cypress/integration/Radio/Can_be_focused.feature new file mode 100644 index 0000000000..c3165b3757 --- /dev/null +++ b/cypress/integration/Radio/Can_be_focused.feature @@ -0,0 +1,6 @@ +Feature: The Radio has an onFocus api + + Scenario: The user focuses the Radio + Given a Radio with onFocus handler is rendered + When the Radio is focused + Then the onFocus handler is called diff --git a/cypress/integration/Radio/Can_be_focused/index.js b/cypress/integration/Radio/Can_be_focused/index.js new file mode 100644 index 0000000000..a44e4168e4 --- /dev/null +++ b/cypress/integration/Radio/Can_be_focused/index.js @@ -0,0 +1,19 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Radio with onFocus handler is rendered', () => { + cy.visitStory('Radio', 'With onFocus') +}) + +When('the Radio is focused', () => { + cy.get('[data-test="dhis2-uicore-radio"] input').focus() +}) + +Then('the onFocus handler is called', () => { + cy.window().then(win => { + expect(win.onFocus).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/ScreenCover/Accepts_children.feature b/cypress/integration/ScreenCover/Accepts_children.feature new file mode 100644 index 0000000000..d5e1f48590 --- /dev/null +++ b/cypress/integration/ScreenCover/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The ScreenCover renders children + + Scenario: A ScreenCover with children + Given a ScreenCover with children is rendered + Then the children are visible diff --git a/cypress/integration/ScreenCover/Accepts_children/index.js b/cypress/integration/ScreenCover/Accepts_children/index.js new file mode 100644 index 0000000000..7bdad4cb19 --- /dev/null +++ b/cypress/integration/ScreenCover/Accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a ScreenCover with children is rendered', () => { + cy.visitStory('ScreenCover', 'With children') + cy.get('[data-test="dhis2-uicore-screencover"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/ScreenCover/Is_clickable.feature b/cypress/integration/ScreenCover/Is_clickable.feature new file mode 100644 index 0000000000..aff017e98f --- /dev/null +++ b/cypress/integration/ScreenCover/Is_clickable.feature @@ -0,0 +1,6 @@ +Feature: The ScreenCover provides an onClick api + + Scenario: The user clicks on the ScreenCover + Given a Screencover with onClick handler is rendered + When the user clicks on the Screencover + Then the onClick handler will be called diff --git a/cypress/integration/ScreenCover/Is_clickable/index.js b/cypress/integration/ScreenCover/Is_clickable/index.js new file mode 100644 index 0000000000..bb82835039 --- /dev/null +++ b/cypress/integration/ScreenCover/Is_clickable/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Screencover with onClick handler is rendered', () => { + cy.visitStory('Screencover', 'With onClick') +}) + +When('the user clicks on the Screencover', () => { + cy.get('[data-test="dhis2-uicore-screencover"]').click() +}) + +Then('the onClick handler will be called', () => { + cy.window().then(win => { + expect(win.onClick).to.be.calledWith({}) + }) +}) diff --git a/cypress/integration/SingleSelect/Accepts_blur_cb.feature b/cypress/integration/SingleSelect/Accepts_blur_cb.feature new file mode 100644 index 0000000000..78ca8474f1 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_blur_cb.feature @@ -0,0 +1,8 @@ +Feature: Calls onBlur cb when blurred + + Scenario: Blurring a SingleSelect through clicking away + Given a SingleSelect with onBlur handler is rendered + And the SingleSelect input is clicked + And the SingleSelect has focus + When the user clicks the backdrop + Then the onBlur handler is called diff --git a/cypress/integration/SingleSelect/Accepts_blur_cb/index.js b/cypress/integration/SingleSelect/Accepts_blur_cb/index.js new file mode 100644 index 0000000000..0582e9b2e0 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_blur_cb/index.js @@ -0,0 +1,15 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with onBlur handler is rendered', () => { + cy.visitStory('SingleSelect', 'With onBlur') +}) + +Then('the onBlur handler is called', () => { + cy.window().then(win => { + expect(win.onBlur).to.be.calledOnce + expect(win.onBlur).to.be.calledWith({ + selected: {}, + }) + }) +}) diff --git a/cypress/integration/SingleSelect/Accepts_focus_cb.feature b/cypress/integration/SingleSelect/Accepts_focus_cb.feature new file mode 100644 index 0000000000..a06d2cf368 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_focus_cb.feature @@ -0,0 +1,6 @@ +Feature: Calls onFocus cb when focused + + Scenario: Focusing a SingleSelect through clicking + Given a SingleSelect with onFocus handler is rendered + When the SingleSelect input is clicked + Then the onFocus handler is called diff --git a/cypress/integration/SingleSelect/Accepts_focus_cb/index.js b/cypress/integration/SingleSelect/Accepts_focus_cb/index.js new file mode 100644 index 0000000000..9785a27b2b --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_focus_cb/index.js @@ -0,0 +1,15 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with onFocus handler is rendered', () => { + cy.visitStory('SingleSelect', 'With onFocus') +}) + +Then('the onFocus handler is called', () => { + cy.window().then(win => { + expect(win.onFocus).to.be.calledOnce + expect(win.onFocus).to.be.calledWith({ + selected: {}, + }) + }) +}) diff --git a/cypress/integration/SingleSelect/Accepts_initial_focus.feature b/cypress/integration/SingleSelect/Accepts_initial_focus.feature new file mode 100644 index 0000000000..17a9a4ed3b --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_initial_focus.feature @@ -0,0 +1,5 @@ +Feature: Focusing the SingleSelect on mount + + Scenario: The SingleSelect renders with focus + Given a SingleSelect with initial focus is rendered + Then the SingleSelect has focus diff --git a/cypress/integration/SingleSelect/Accepts_initial_focus/index.js b/cypress/integration/SingleSelect/Accepts_initial_focus/index.js new file mode 100644 index 0000000000..443dd8e172 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_initial_focus/index.js @@ -0,0 +1,6 @@ +import '../common' +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with initial focus is rendered', () => { + cy.visitStory('SingleSelect', 'With initialFocus') +}) diff --git a/cypress/integration/SingleSelect/Accepts_loading.feature b/cypress/integration/SingleSelect/Accepts_loading.feature new file mode 100644 index 0000000000..69a5b5f89a --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_loading.feature @@ -0,0 +1,14 @@ +Feature: Loading status + + Scenario: The user opens a loading SingleSelect + Given a SingleSelect with options and a loading flag is rendered + When the SingleSelect input is clicked + Then the loading spinner is displayed + And the options are displayed + + Scenario: The user opens a loading SingleSelect with a loading text + Given a SingleSelect with options, a loading flag and a loading text is rendered + When the SingleSelect input is clicked + Then the loading spinner is displayed + And the loading text is displayed + And the options are displayed diff --git a/cypress/integration/SingleSelect/Accepts_loading/index.js b/cypress/integration/SingleSelect/Accepts_loading/index.js new file mode 100644 index 0000000000..d99580c263 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_loading/index.js @@ -0,0 +1,23 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with options and a loading flag is rendered', () => { + cy.visitStory('SingleSelect', 'With options and loading') +}) + +Given( + 'a SingleSelect with options, a loading flag and a loading text is rendered', + () => { + cy.visitStory('SingleSelect', 'With options, loading and loading text') + } +) + +Then('the loading spinner is displayed', () => { + cy.get('[data-test="dhis2-uicore-circularloader"]').should('be.visible') +}) + +Then('the loading text is displayed', () => { + cy.get('[data-test="dhis2-uicore-singleselect-loading"]') + .contains('Loading options') + .should('be.visible') +}) diff --git a/cypress/integration/SingleSelect/Accepts_max_height.feature b/cypress/integration/SingleSelect/Accepts_max_height.feature new file mode 100644 index 0000000000..1ad2d503b6 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_max_height.feature @@ -0,0 +1,11 @@ +Feature: Setting a max-height + + Scenario: The SingleSelect does not have an explicit max-height set + Given a SingleSelect with more than ten options is rendered + And the SingleSelect is open + Then the top nine options are displayed + + Scenario: The SingleSelect a max-height of 100px set + Given a SingleSelect with more than three options and a 100px max-height is rendered + And the SingleSelect is open + Then the top three options are displayed diff --git a/cypress/integration/SingleSelect/Accepts_max_height/index.js b/cypress/integration/SingleSelect/Accepts_max_height/index.js new file mode 100644 index 0000000000..c8160d6966 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_max_height/index.js @@ -0,0 +1,50 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with more than ten options is rendered', () => { + cy.visitStory('SingleSelect', 'With more than ten options') +}) + +Given( + 'a SingleSelect with more than three options and a 100px max-height is rendered', + () => { + cy.visitStory( + 'SingleSelect', + 'With more than three options and a 100px max-height' + ) + } +) + +Given('the SingleSelect is open', () => { + cy.get('[data-test="dhis2-uicore-select"]').click() +}) + +Then('the top nine options are displayed', () => { + cy.contains('option one').should('be.visible') + cy.contains('option two').should('be.visible') + cy.contains('option three').should('be.visible') + cy.contains('option four').should('be.visible') + cy.contains('option five').should('be.visible') + cy.contains('option six').should('be.visible') + cy.contains('option seven').should('be.visible') + cy.contains('option eight').should('be.visible') + cy.contains('option nine').should('be.visible') + cy.contains('option ten').should('not.be.visible') + cy.contains('option eleven').should('not.be.visible') + cy.contains('option twelve').should('not.be.visible') +}) + +Then('the top three options are displayed', () => { + cy.contains('option one').should('be.visible') + cy.contains('option two').should('be.visible') + cy.contains('option three').should('be.visible') + cy.contains('option four').should('not.be.visible') + cy.contains('option five').should('not.be.visible') + cy.contains('option six').should('not.be.visible') + cy.contains('option seven').should('not.be.visible') + cy.contains('option eight').should('not.be.visible') + cy.contains('option nine').should('not.be.visible') + cy.contains('option ten').should('not.be.visible') + cy.contains('option eleven').should('not.be.visible') + cy.contains('option twelve').should('not.be.visible') +}) diff --git a/cypress/integration/SingleSelect/Accepts_placeholder.feature b/cypress/integration/SingleSelect/Accepts_placeholder.feature new file mode 100644 index 0000000000..a15aa02116 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_placeholder.feature @@ -0,0 +1,10 @@ +Feature: Placeholder text + + Scenario: A placeholder text with no selection + Given a SingleSelect with a placeholder and no selection is rendered + Then the placeholder is shown + + Scenario: A placeholder text with a selection + Given a SingleSelect with a placeholder and a selection is rendered + Then the placeholder is not shown + And the selection is displayed diff --git a/cypress/integration/SingleSelect/Accepts_placeholder/index.js b/cypress/integration/SingleSelect/Accepts_placeholder/index.js new file mode 100644 index 0000000000..deec6d9c78 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_placeholder/index.js @@ -0,0 +1,26 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with a placeholder and no selection is rendered', () => { + cy.visitStory('SingleSelect', 'With placeholder') +}) + +Given('a SingleSelect with a placeholder and a selection is rendered', () => { + cy.visitStory('SingleSelect', 'With placeholder and selection') +}) + +Then('the placeholder is shown', () => { + cy.get('[data-test="dhis2-uicore-singleselect-placeholder"]') + .contains('Placeholder text') + .should('be.visible') +}) + +Then('the placeholder is not shown', () => { + cy.get('[data-test="dhis2-uicore-singleselect-placeholder"]').should( + 'not.be.visible' + ) +}) + +Then('the selection is displayed', () => { + cy.contains('option one').should('be.visible') +}) diff --git a/cypress/integration/SingleSelect/Accepts_prefix.feature b/cypress/integration/SingleSelect/Accepts_prefix.feature new file mode 100644 index 0000000000..aaec0ebf71 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_prefix.feature @@ -0,0 +1,10 @@ +Feature: Placeholder text + + Scenario: A prefix text with no selection + Given a SingleSelect with a prefix and no selection is rendered + Then the prefix is shown + + Scenario: A prefix text with a selection + Given a SingleSelect with a prefix and a selection is rendered + Then the prefix is shown + And the selection is shown diff --git a/cypress/integration/SingleSelect/Accepts_prefix/index.js b/cypress/integration/SingleSelect/Accepts_prefix/index.js new file mode 100644 index 0000000000..9e13767123 --- /dev/null +++ b/cypress/integration/SingleSelect/Accepts_prefix/index.js @@ -0,0 +1,20 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with a prefix and no selection is rendered', () => { + cy.visitStory('SingleSelect', 'With prefix') +}) + +Given('a SingleSelect with a prefix and a selection is rendered', () => { + cy.visitStory('SingleSelect', 'With prefix and selection') +}) + +Then('the prefix is shown', () => { + cy.get('[data-test="dhis2-uicore-singleselect-prefix"]') + .contains('Prefix text') + .should('be.visible') +}) + +Then('the selection is shown', () => { + cy.contains('option one').should('be.visible') +}) diff --git a/cypress/integration/SingleSelect/Allows_invalid_options.feature b/cypress/integration/SingleSelect/Allows_invalid_options.feature new file mode 100644 index 0000000000..f667823f64 --- /dev/null +++ b/cypress/integration/SingleSelect/Allows_invalid_options.feature @@ -0,0 +1,12 @@ +Feature: Invalid SingleSelect options + + Scenario: Rendering a SingleSelect with invalid options + Given a SingleSelect with invalid options is rendered + And the SingleSelect is open + Then the invalid options are displayed + + Scenario: Rendering a SingleSelect with invalid filterable options + Given a SingleSelect with invalid filterable options is rendered + And the SingleSelect is open + When the user enters a filter string + Then the invalid options are not displayed diff --git a/cypress/integration/SingleSelect/Allows_invalid_options/index.js b/cypress/integration/SingleSelect/Allows_invalid_options/index.js new file mode 100644 index 0000000000..13b4378f94 --- /dev/null +++ b/cypress/integration/SingleSelect/Allows_invalid_options/index.js @@ -0,0 +1,29 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with invalid options is rendered', () => { + cy.visitStory('SingleSelect', 'With invalid options') +}) + +Given('a SingleSelect with invalid filterable options is rendered', () => { + cy.visitStory('SingleSelect', 'With invalid filterable options') +}) + +When('the user enters a filter string', () => { + cy.get('[data-test="dhis2-uicore-singleselect-filterinput"]') + .click() + .focused() + .type('invalid') +}) + +Then('the invalid options are displayed', () => { + cy.contains('invalid one').should('be.visible') + cy.contains('invalid two').should('be.visible') + cy.contains('invalid three').should('be.visible') +}) + +Then('the invalid options are not displayed', () => { + cy.contains('invalid one').should('not.be.visible') + cy.contains('invalid two').should('not.be.visible') + cy.contains('invalid three').should('not.be.visible') +}) diff --git a/cypress/integration/SingleSelect/Allows_selecting.feature b/cypress/integration/SingleSelect/Allows_selecting.feature new file mode 100644 index 0000000000..6d7714e62a --- /dev/null +++ b/cypress/integration/SingleSelect/Allows_selecting.feature @@ -0,0 +1,21 @@ +Feature: Selecting options + + Scenario: The user clicks an option to select it + Given a SingleSelect with options and onChange handler is rendered + And the SingleSelect is open + When an option is clicked + Then the clicked option is selected + And the options are not displayed + + Scenario: The user clicks a custom option to select it + Given a SingleSelect with custom options and onChange handler is rendered + And the SingleSelect is open + When an option is clicked + Then the clicked option is selected + And the options are not displayed + + Scenario: The user clicks a disabled option + Given a SingleSelect with a disabled option and onChange handler is rendered + And the SingleSelect is open + When the disabled option is clicked + Then the onchange handler is not called diff --git a/cypress/integration/SingleSelect/Allows_selecting/index.js b/cypress/integration/SingleSelect/Allows_selecting/index.js new file mode 100644 index 0000000000..9db09f641e --- /dev/null +++ b/cypress/integration/SingleSelect/Allows_selecting/index.js @@ -0,0 +1,39 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a SingleSelect with a disabled option and onChange handler is rendered', + () => { + cy.visitStory('SingleSelect', 'With disabled option and onChange') + } +) + +Given( + 'a SingleSelect with custom options and onChange handler is rendered', + () => { + cy.visitStory('SingleSelect', 'With custom options and onChange') + } +) + +When('an option is clicked', () => { + cy.contains('option one').click() +}) + +When('the disabled option is clicked', () => { + cy.contains('disabled option').click() +}) + +Then('the clicked option is selected', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ + selected: { label: 'option one', value: '1' }, + }) + }) +}) + +Then('the onchange handler is not called', () => { + cy.window().then(win => { + expect(win.onChange).to.not.be.called + }) +}) diff --git a/cypress/integration/SingleSelect/Can_be_cleared.feature b/cypress/integration/SingleSelect/Can_be_cleared.feature new file mode 100644 index 0000000000..83a8683276 --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_cleared.feature @@ -0,0 +1,6 @@ +Feature: Clearing the SingleSelect + + Scenario: The user clicks the clear button to clear the SingleSelect + Given a clearable SingleSelect with a selection and onchange handler is rendered + When the clear button is clicked + Then the SingleSelect is cleared diff --git a/cypress/integration/SingleSelect/Can_be_cleared/index.js b/cypress/integration/SingleSelect/Can_be_cleared/index.js new file mode 100644 index 0000000000..8f63757515 --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_cleared/index.js @@ -0,0 +1,23 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a clearable SingleSelect with a selection and onchange handler is rendered', + () => { + cy.visitStory( + 'SingleSelect', + 'With clear button, selection and onChange' + ) + } +) + +When('the clear button is clicked', () => { + cy.get('[data-test="dhis2-uicore-singleselect-clear"]').click() +}) + +Then('the SingleSelect is cleared', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ selected: {} }) + }) +}) diff --git a/cypress/integration/SingleSelect/Can_be_disabled.feature b/cypress/integration/SingleSelect/Can_be_disabled.feature new file mode 100644 index 0000000000..1eab15d8e3 --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_disabled.feature @@ -0,0 +1,32 @@ +Feature: Disabled SingleSelect + + Scenario: The disabled singleselect has a selection + Given a disabled SingleSelect with options and a selection is rendered + Then the selection is displayed + + Scenario: The user clicks a disabled SingleSelect + Given a disabled SingleSelect with options is rendered + And the SingleSelect is closed + When the SingleSelect input is clicked + Then the options are not displayed + + Scenario: The user presses the down arrowkey + Given a disabled SingleSelect with options is rendered + And the SingleSelect is closed + And the SingleSelect is focused + When the down arrowkey is pressed on the focused element + Then the options are not displayed + + Scenario: The user presses the up arrowkey + Given a disabled SingleSelect with options is rendered + And the SingleSelect is closed + And the SingleSelect is focused + When the up arrowkey is pressed on the focused element + Then the options are not displayed + + Scenario: The user presses the spacebar + Given a disabled SingleSelect with options is rendered + And the SingleSelect is closed + And the SingleSelect is focused + When the spacebar is pressed on the focused element + Then the options are not displayed diff --git a/cypress/integration/SingleSelect/Can_be_disabled/index.js b/cypress/integration/SingleSelect/Can_be_disabled/index.js new file mode 100644 index 0000000000..a26be7a8ae --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_disabled/index.js @@ -0,0 +1,40 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a disabled SingleSelect with options is rendered', () => { + cy.visitStory('SingleSelect', 'With options and disabled') +}) + +Given( + 'a disabled SingleSelect with options and a selection is rendered', + () => { + cy.visitStory('SingleSelect', 'With options, a selection and disabled') + } +) + +Given('the SingleSelect is closed', () => { + cy.contains('option one').should('not.exist') + cy.contains('option two').should('not.exist') + cy.contains('option three').should('not.exist') +}) + +Given('the SingleSelect is focused', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').focus() + cy.focused().should('have.attr', 'data-test', 'dhis2-uicore-select-input') +}) + +When('the down arrowkey is pressed on the focused element', () => { + cy.focused().type('{downarrow}') +}) + +When('the spacebar is pressed on the focused element', () => { + cy.focused().type(' ') +}) + +When('the up arrowkey is pressed on the focused element', () => { + cy.focused().type('{uparrow}') +}) + +Then('the selection is displayed', () => { + cy.contains('option one').should('be.visible') +}) diff --git a/cypress/integration/SingleSelect/Can_be_empty.feature b/cypress/integration/SingleSelect/Can_be_empty.feature new file mode 100644 index 0000000000..bc99f7f500 --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_empty.feature @@ -0,0 +1,16 @@ +Feature: Loading status + + Scenario: The user opens an empty SingleSelect + Given an empty SingleSelect is rendered + When the SingleSelect input is clicked + Then an empty menu is displayed + + Scenario: The user opens an empty SingleSelect with custom empty text + Given an empty SingleSelect with custom empty text is rendered + When the SingleSelect input is clicked + Then the custom empty text is displayed + + Scenario: The user opens an empty SingleSelect with a custom empty component + Given an empty SingleSelect with custom empty component is rendered + When the SingleSelect input is clicked + Then the custom empty component is displayed diff --git a/cypress/integration/SingleSelect/Can_be_empty/index.js b/cypress/integration/SingleSelect/Can_be_empty/index.js new file mode 100644 index 0000000000..ae094d229e --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_empty/index.js @@ -0,0 +1,29 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an empty SingleSelect is rendered', () => { + cy.visitStory('SingleSelect', 'Empty') +}) + +Given('an empty SingleSelect with custom empty text is rendered', () => { + cy.visitStory('SingleSelect', 'Empty with empty text') +}) + +Given('an empty SingleSelect with custom empty component is rendered', () => { + cy.visitStory('SingleSelect', 'Empty with empty component') +}) + +Then('an empty menu is displayed', () => { + cy.get('[data-test="dhis2-uicore-backdrop"]').should('exist') +}) + +Then('the custom empty text is displayed', () => { + cy.get('[data-test="dhis2-uicore-singleselect-empty"]') + .contains('Custom empty text') + .should('be.visible') +}) + +Then('the custom empty component is displayed', () => { + cy.contains('Custom empty component').should('be.visible') + cy.get('.custom-empty').should('exist') +}) diff --git a/cypress/integration/SingleSelect/Can_be_filtered.feature b/cypress/integration/SingleSelect/Can_be_filtered.feature new file mode 100644 index 0000000000..d857c1e8f8 --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_filtered.feature @@ -0,0 +1,14 @@ +Feature: Filtering the SingleSelect options + + Scenario: The user enters a filter string to filter the options + Given a filterable SingleSelect with options is rendered + And the SingleSelect is open + When the user enters a filter string + Then the matching options are displayed + + Scenario: The user enters a filter string that doesn't match any options + Given a filterable SingleSelect with options is rendered + And the SingleSelect is open + When the user enters a filter string that doesn't match any options + Then the no match text is displayed + And the options are not displayed diff --git a/cypress/integration/SingleSelect/Can_be_filtered/index.js b/cypress/integration/SingleSelect/Can_be_filtered/index.js new file mode 100644 index 0000000000..ccbed6415f --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_filtered/index.js @@ -0,0 +1,24 @@ +import '../common' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a filterable SingleSelect with options is rendered', () => { + cy.visitStory('SingleSelect', 'With filter field') +}) + +When('the user enters a filter string', () => { + cy.focused().type('one') +}) + +When("the user enters a filter string that doesn't match any options", () => { + cy.focused().type('does not exist') +}) + +Then('the matching options are displayed', () => { + cy.contains('option one').should('be.visible') + cy.contains('option two').should('not.be.visible') + cy.contains('option three').should('not.be.visible') +}) + +Then('the no match text is displayed', () => { + cy.contains('No match found').should('be.visible') +}) diff --git a/cypress/integration/SingleSelect/Can_be_opened_and_closed.feature b/cypress/integration/SingleSelect/Can_be_opened_and_closed.feature new file mode 100644 index 0000000000..30e2f72432 --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_opened_and_closed.feature @@ -0,0 +1,41 @@ +Feature: Opening and closing the SingleSelect + + Scenario: The user clicks the SingleSelect input to display the options + Given a SingleSelect with options is rendered + And the SingleSelect is closed + When the SingleSelect input is clicked + Then the options are displayed + + Scenario: The user presses the down arrowkey to display the options + Given a SingleSelect with options is rendered + And the SingleSelect is closed + And the SingleSelect is focused + When the down arrowkey is pressed on the focused element + Then the options are displayed + + Scenario: The user presses the up arrowkey to display the options + Given a SingleSelect with options is rendered + And the SingleSelect is closed + And the SingleSelect is focused + When the up arrowkey is pressed on the focused element + Then the options are displayed + + Scenario: The user presses the spacebar to display the options + Given a SingleSelect with options is rendered + And the SingleSelect is closed + And the SingleSelect is focused + When the spacebar is pressed on the focused element + Then the options are displayed + + Scenario: The user clicks the backdrop to hide the options + Given a SingleSelect with options is rendered + And the SingleSelect is open + When the user clicks the backdrop + Then the options are not displayed + + Scenario: The user presses the escape key to hide the options + Given a SingleSelect with options is rendered + And the SingleSelect is open + And the SingleSelect is focused + When the escape key is pressed on the focused element + Then the options are not displayed diff --git a/cypress/integration/SingleSelect/Can_be_opened_and_closed/index.js b/cypress/integration/SingleSelect/Can_be_opened_and_closed/index.js new file mode 100644 index 0000000000..0675629e4d --- /dev/null +++ b/cypress/integration/SingleSelect/Can_be_opened_and_closed/index.js @@ -0,0 +1,33 @@ +import '../common' +import { Given, When } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with options is rendered', () => { + cy.visitStory('SingleSelect', 'With options') +}) + +Given('the SingleSelect is closed', () => { + cy.contains('option one').should('not.exist') + cy.contains('option two').should('not.exist') + cy.contains('option three').should('not.exist') +}) + +Given('the SingleSelect is focused', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').focus() + cy.focused().should('have.attr', 'data-test', 'dhis2-uicore-select-input') +}) + +When('the down arrowkey is pressed on the focused element', () => { + cy.focused().type('{downarrow}') +}) + +When('the spacebar is pressed on the focused element', () => { + cy.focused().type(' ') +}) + +When('the up arrowkey is pressed on the focused element', () => { + cy.focused().type('{uparrow}') +}) + +When('the escape key is pressed on the focused element', () => { + cy.focused().type('{esc}') +}) diff --git a/cypress/integration/SingleSelect/Shows_selections.feature b/cypress/integration/SingleSelect/Shows_selections.feature new file mode 100644 index 0000000000..ea6d154b6c --- /dev/null +++ b/cypress/integration/SingleSelect/Shows_selections.feature @@ -0,0 +1,5 @@ +Feature: Show current selections + + Scenario: The singleselect has a selection + Given a SingleSelect with options and a selection is rendered + Then the selection is displayed diff --git a/cypress/integration/SingleSelect/Shows_selections/index.js b/cypress/integration/SingleSelect/Shows_selections/index.js new file mode 100644 index 0000000000..c742aa5778 --- /dev/null +++ b/cypress/integration/SingleSelect/Shows_selections/index.js @@ -0,0 +1,10 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with options and a selection is rendered', () => { + cy.visitStory('SingleSelect', 'With options and a selection') +}) + +Then('the selection is displayed', () => { + cy.contains('option one').should('be.visible') +}) diff --git a/cypress/integration/SingleSelect/common/index.js b/cypress/integration/SingleSelect/common/index.js new file mode 100644 index 0000000000..501b26c7b8 --- /dev/null +++ b/cypress/integration/SingleSelect/common/index.js @@ -0,0 +1,44 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelect with options and onChange handler is rendered', () => { + cy.visitStory('SingleSelect', 'With options and onChange') +}) + +Given( + 'a SingleSelect with options, a selection and onChange handler is rendered', + () => { + cy.visitStory('SingleSelect', 'With options, a selection and onChange') + } +) + +Given('the SingleSelect is open', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() + + cy.contains('option one').should('exist') + cy.contains('option two').should('exist') + cy.contains('option three').should('exist') +}) + +When('the SingleSelect input is clicked', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +When('the user clicks the backdrop', () => { + cy.get('[data-test="dhis2-uicore-backdrop"]').click() +}) + +Then('the options are not displayed', () => { + cy.contains('option one').should('not.be.visible') + cy.contains('option two').should('not.be.visible') + cy.contains('option three').should('not.be.visible') +}) + +Then('the options are displayed', () => { + cy.contains('option one').should('be.visible') + cy.contains('option two').should('be.visible') + cy.contains('option three').should('be.visible') +}) + +Then('the SingleSelect has focus', () => { + cy.focused().should('have.attr', 'data-test', 'dhis2-uicore-select-input') +}) diff --git a/cypress/integration/SingleSelectField/Accepts_help_text.feature b/cypress/integration/SingleSelectField/Accepts_help_text.feature new file mode 100644 index 0000000000..f2bc3306fb --- /dev/null +++ b/cypress/integration/SingleSelectField/Accepts_help_text.feature @@ -0,0 +1,5 @@ +Feature: Help text for the SingleSelectField + + Scenario: Rendering a SingleSelectField with help text + Given a SingleSelectField with help text is rendered + Then the help text is visible diff --git a/cypress/integration/SingleSelectField/Accepts_help_text/index.js b/cypress/integration/SingleSelectField/Accepts_help_text/index.js new file mode 100644 index 0000000000..3c35742730 --- /dev/null +++ b/cypress/integration/SingleSelectField/Accepts_help_text/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelectField with help text is rendered', () => { + cy.visitStory('SingleSelectField', 'With help text') +}) + +Then('the help text is visible', () => { + cy.contains('The help text').should('be.visible') +}) diff --git a/cypress/integration/SingleSelectField/Accepts_label.feature b/cypress/integration/SingleSelectField/Accepts_label.feature new file mode 100644 index 0000000000..3efde4897e --- /dev/null +++ b/cypress/integration/SingleSelectField/Accepts_label.feature @@ -0,0 +1,5 @@ +Feature: Label for the SingleSelectField + + Scenario: Rendering a SingleSelectField with a label + Given a SingleSelectField with a label is rendered + Then the label is visible diff --git a/cypress/integration/SingleSelectField/Accepts_label/index.js b/cypress/integration/SingleSelectField/Accepts_label/index.js new file mode 100644 index 0000000000..572734c174 --- /dev/null +++ b/cypress/integration/SingleSelectField/Accepts_label/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelectField with a label is rendered', () => { + cy.visitStory('SingleSelectField', 'With label') +}) + +Then('the label is visible', () => { + cy.contains('The label').should('be.visible') +}) diff --git a/cypress/integration/SingleSelectField/Accepts_validation_text.feature b/cypress/integration/SingleSelectField/Accepts_validation_text.feature new file mode 100644 index 0000000000..f244bdfeea --- /dev/null +++ b/cypress/integration/SingleSelectField/Accepts_validation_text.feature @@ -0,0 +1,5 @@ +Feature: Validation text for the SingleSelectField + + Scenario: Rendering a SingleSelectField with validation text + Given a SingleSelectField with validation text is rendered + Then the validation text is visible diff --git a/cypress/integration/SingleSelectField/Accepts_validation_text/index.js b/cypress/integration/SingleSelectField/Accepts_validation_text/index.js new file mode 100644 index 0000000000..2a364efa56 --- /dev/null +++ b/cypress/integration/SingleSelectField/Accepts_validation_text/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelectField with validation text is rendered', () => { + cy.visitStory('SingleSelectField', 'With validation text') +}) + +Then('the validation text is visible', () => { + cy.contains('The validation text').should('be.visible') +}) diff --git a/cypress/integration/SingleSelectField/Can_be_required.feature b/cypress/integration/SingleSelectField/Can_be_required.feature new file mode 100644 index 0000000000..e0812b584f --- /dev/null +++ b/cypress/integration/SingleSelectField/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the SingleSelectField + + Scenario: Rendering a SingleSelectField that is required + Given a SingleSelectField with label and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/SingleSelectField/Can_be_required/index.js b/cypress/integration/SingleSelectField/Can_be_required/index.js new file mode 100644 index 0000000000..4f85a0f454 --- /dev/null +++ b/cypress/integration/SingleSelectField/Can_be_required/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SingleSelectField with label and a required flag is rendered', () => { + cy.visitStory('SingleSelectField', 'With label and required status') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-label-required"]').should('be.visible') +}) diff --git a/cypress/integration/SplitButton/Accepts_children.feature b/cypress/integration/SplitButton/Accepts_children.feature new file mode 100644 index 0000000000..f31607bdb0 --- /dev/null +++ b/cypress/integration/SplitButton/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The SplitButton renders children + + Scenario: A SplitButton with children + Given a SplitButton with children is rendered + Then the children are visible diff --git a/cypress/integration/SplitButton/Accepts_children/index.js b/cypress/integration/SplitButton/Accepts_children/index.js new file mode 100644 index 0000000000..0061c2efd7 --- /dev/null +++ b/cypress/integration/SplitButton/Accepts_children/index.js @@ -0,0 +1,12 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SplitButton with children is rendered', () => { + cy.visitStory('SplitButton', 'With children') + cy.get('[data-test="dhis2-uicore-splitbutton"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-button"]') + .contains('I am a child') + .should('be.visible') +}) diff --git a/cypress/integration/SplitButton/Accepts_icon.feature b/cypress/integration/SplitButton/Accepts_icon.feature new file mode 100644 index 0000000000..1f63ef3c31 --- /dev/null +++ b/cypress/integration/SplitButton/Accepts_icon.feature @@ -0,0 +1,5 @@ +Feature: The SplitButton renders icon + + Scenario: A SplitButton with an icon + Given a SplitButton with an icon is rendered + Then the icon is visible on both buttons diff --git a/cypress/integration/SplitButton/Accepts_icon/index.js b/cypress/integration/SplitButton/Accepts_icon/index.js new file mode 100644 index 0000000000..1b7ab433f7 --- /dev/null +++ b/cypress/integration/SplitButton/Accepts_icon/index.js @@ -0,0 +1,16 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SplitButton with an icon is rendered', () => { + cy.visitStory('SplitButton', 'With icon') + cy.get('[data-test="dhis2-uicore-splitbutton"]').should('be.visible') +}) + +Then('the icon is visible on both buttons', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-button"]') + .contains('Icon') + .should('be.visible') + + cy.get('[data-test="dhis2-uicore-splitbutton-toggle"]') + .contains('Icon') + .should('be.visible') +}) diff --git a/cypress/integration/SplitButton/Accepts_initial_focus.feature b/cypress/integration/SplitButton/Accepts_initial_focus.feature new file mode 100644 index 0000000000..cda05f64ea --- /dev/null +++ b/cypress/integration/SplitButton/Accepts_initial_focus.feature @@ -0,0 +1,5 @@ +Feature: Focusing the SplitButton on mount + + Scenario: The SplitButton renders with focus + Given a SplitButton with initialFocus is rendered + Then the SplitButton button is focused diff --git a/cypress/integration/SplitButton/Accepts_initial_focus/index.js b/cypress/integration/SplitButton/Accepts_initial_focus/index.js new file mode 100644 index 0000000000..2e9a133e19 --- /dev/null +++ b/cypress/integration/SplitButton/Accepts_initial_focus/index.js @@ -0,0 +1,13 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SplitButton with initialFocus is rendered', () => { + cy.visitStory('SplitButton', 'With initialFocus') +}) + +Then('the SplitButton button is focused', () => { + cy.focused().should( + 'have.attr', + 'data-test', + 'dhis2-uicore-splitbutton-button' + ) +}) diff --git a/cypress/integration/SplitButton/Arrow_opens_menu.feature b/cypress/integration/SplitButton/Arrow_opens_menu.feature new file mode 100644 index 0000000000..77437306ac --- /dev/null +++ b/cypress/integration/SplitButton/Arrow_opens_menu.feature @@ -0,0 +1,15 @@ +Feature: The SplitButton renders a Popper + + Scenario: The user opens the Popper + Given a SplitButton is rendered + And the SplitButton menu is closed + When the SplitButton toggle is clicked + Then the menu is visible + And the component is visible + + Scenario: The user closes the Popper + Given a SplitButton is rendered + And the SplitButton menu is open + When the Backdrop is clicked + Then the menu is not visible + And the component is not visible diff --git a/cypress/integration/SplitButton/Arrow_opens_menu/index.js b/cypress/integration/SplitButton/Arrow_opens_menu/index.js new file mode 100644 index 0000000000..08571fa88d --- /dev/null +++ b/cypress/integration/SplitButton/Arrow_opens_menu/index.js @@ -0,0 +1,39 @@ +import '../common/index' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SplitButton is rendered', () => { + cy.visitStory('SplitButton', 'Default') +}) + +Given('the SplitButton menu is open', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-toggle"]').click() + cy.get('[data-test="dhis2-uicore-splitbutton-menu"]').should('be.visible') +}) + +Given('the SplitButton menu is closed', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-menu"]').should( + 'not.be.visible' + ) +}) + +When('the Backdrop is clicked', () => { + cy.get('[data-test="dhis2-uicore-backdrop"]').click() +}) + +Then('the menu is not visible', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-menu"]').should( + 'not.be.visible' + ) +}) + +Then('the component is not visible', () => { + cy.contains('Component').should('not.be.visible') +}) + +Then('the menu is visible', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-menu"]').should('be.visible') +}) + +Then('the component is visible', () => { + cy.contains('Component').should('be.visible') +}) diff --git a/cypress/integration/SplitButton/Button_is_clickable.feature b/cypress/integration/SplitButton/Button_is_clickable.feature new file mode 100644 index 0000000000..a5fb36abf3 --- /dev/null +++ b/cypress/integration/SplitButton/Button_is_clickable.feature @@ -0,0 +1,6 @@ +Feature: The SplitButton has an onClick api + + Scenario: The user clicks on the SplitButton + Given a SplitButton with onClick hander is rendered + When the SplitButton is clicked + Then the onClick handler is called diff --git a/cypress/integration/SplitButton/Button_is_clickable/index.js b/cypress/integration/SplitButton/Button_is_clickable/index.js new file mode 100644 index 0000000000..d31b526ce0 --- /dev/null +++ b/cypress/integration/SplitButton/Button_is_clickable/index.js @@ -0,0 +1,16 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SplitButton with onClick hander is rendered', () => { + cy.visitStory('SplitButton', 'With onClick') +}) + +Then('the onClick handler is called', () => { + cy.window() + .its('onClick') + .should('be.calledWith', { + name: 'Button', + value: 'default', + open: false, + }) +}) diff --git a/cypress/integration/SplitButton/Can_be_disabled.feature b/cypress/integration/SplitButton/Can_be_disabled.feature new file mode 100644 index 0000000000..f2e45db204 --- /dev/null +++ b/cypress/integration/SplitButton/Can_be_disabled.feature @@ -0,0 +1,11 @@ +Feature: The SplitButton can be disabled + + Scenario: The user clicks a disabled SplitButton Button + Given a disabled SplitButton is rendered + When the user clicks the SplitButton Button + Then the SplitButton Button is not focused + + Scenario: The user clicks a disabled SplitButton Toggle + Given a disabled SplitButton is rendered + When the user clicks the SplitButton Toggle + Then the SplitButton Toggle is not focused diff --git a/cypress/integration/SplitButton/Can_be_disabled/index.js b/cypress/integration/SplitButton/Can_be_disabled/index.js new file mode 100644 index 0000000000..cfc44d2415 --- /dev/null +++ b/cypress/integration/SplitButton/Can_be_disabled/index.js @@ -0,0 +1,25 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a disabled SplitButton is rendered', () => { + cy.visitStory('SplitButton', 'With disabled') +}) + +When('the user clicks the SplitButton Button', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-button"]').click({ + force: true, + }) +}) + +Then('the SplitButton Button is not focused', () => { + cy.focused().should('not.exist') +}) + +When('the user clicks the SplitButton Toggle', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-toggle"]').click({ + force: true, + }) +}) + +Then('the SplitButton Toggle is not focused', () => { + cy.focused().should('not.exist') +}) diff --git a/cypress/integration/SplitButton/common/index.js b/cypress/integration/SplitButton/common/index.js new file mode 100644 index 0000000000..eb4247401d --- /dev/null +++ b/cypress/integration/SplitButton/common/index.js @@ -0,0 +1,9 @@ +import { When } from 'cypress-cucumber-preprocessor/steps' + +When('the SplitButton is clicked', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-button"]').click() +}) + +When('the SplitButton toggle is clicked', () => { + cy.get('[data-test="dhis2-uicore-splitbutton-toggle"]').click() +}) diff --git a/cypress/integration/Switch/Accepts_initial_focus.feature b/cypress/integration/Switch/Accepts_initial_focus.feature new file mode 100644 index 0000000000..4f11d24aa0 --- /dev/null +++ b/cypress/integration/Switch/Accepts_initial_focus.feature @@ -0,0 +1,5 @@ +Feature: Focusing the Switch on mount + + Scenario: The Switch renders with focus + Given a Switch with initialFocus is rendered + Then the Switch is focused diff --git a/cypress/integration/Switch/Accepts_initial_focus/index.js b/cypress/integration/Switch/Accepts_initial_focus/index.js new file mode 100644 index 0000000000..a9322440fb --- /dev/null +++ b/cypress/integration/Switch/Accepts_initial_focus/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Switch with initialFocus is rendered', () => { + cy.visitStory('Switch', 'With initialFocus') +}) + +Then('the Switch is focused', () => { + cy.focused() + .parent('[data-test="dhis2-uicore-switch"]') + .should('exist') +}) diff --git a/cypress/integration/Switch/Accepts_label.feature b/cypress/integration/Switch/Accepts_label.feature new file mode 100644 index 0000000000..63288c839b --- /dev/null +++ b/cypress/integration/Switch/Accepts_label.feature @@ -0,0 +1,5 @@ +Feature: The Switch shows a label + + Scenario: The Switch has a label + Given a Switch with a label is rendered + Then the label is shown diff --git a/cypress/integration/Switch/Accepts_label/index.js b/cypress/integration/Switch/Accepts_label/index.js new file mode 100644 index 0000000000..cd4b50dbb8 --- /dev/null +++ b/cypress/integration/Switch/Accepts_label/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Switch with a label is rendered', () => { + cy.visitStory('Switch', 'With label') +}) + +Then('the label is shown', () => { + cy.get('[data-test="dhis2-uicore-switch"]') + .contains('The label') + .should('be.visible') +}) diff --git a/cypress/integration/Switch/Can_be_blurred.feature b/cypress/integration/Switch/Can_be_blurred.feature new file mode 100644 index 0000000000..1eabfab465 --- /dev/null +++ b/cypress/integration/Switch/Can_be_blurred.feature @@ -0,0 +1,6 @@ +Feature: The Switch has an onBlur api + + Scenario: The user blurs the Switch + Given a Switch with initialFocus and onBlur handler is rendered + When the Switch is blurred + Then the onBlur handler is called diff --git a/cypress/integration/Switch/Can_be_blurred/index.js b/cypress/integration/Switch/Can_be_blurred/index.js new file mode 100644 index 0000000000..d96639f6f2 --- /dev/null +++ b/cypress/integration/Switch/Can_be_blurred/index.js @@ -0,0 +1,19 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Switch with initialFocus and onBlur handler is rendered', () => { + cy.visitStory('Switch', 'With initialFocus and onBlur') +}) + +When('the Switch is blurred', () => { + cy.get('[data-test="dhis2-uicore-switch"] input').blur() +}) + +Then('the onBlur handler is called', () => { + cy.window().then(win => { + expect(win.onBlur).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/Switch/Can_be_changed.feature b/cypress/integration/Switch/Can_be_changed.feature new file mode 100644 index 0000000000..326a1694d1 --- /dev/null +++ b/cypress/integration/Switch/Can_be_changed.feature @@ -0,0 +1,6 @@ +Feature: The Switch has an onChange api + + Scenario: The user clicks the Switch + Given a Switch with onChange handler is rendered + When the Switch is clicked + Then the onChange handler is called diff --git a/cypress/integration/Switch/Can_be_changed/index.js b/cypress/integration/Switch/Can_be_changed/index.js new file mode 100644 index 0000000000..446436910e --- /dev/null +++ b/cypress/integration/Switch/Can_be_changed/index.js @@ -0,0 +1,19 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Switch with onChange handler is rendered', () => { + cy.visitStory('Switch', 'With onChange') +}) + +When('the Switch is clicked', () => { + cy.get('[data-test="dhis2-uicore-switch"]').click() +}) + +Then('the onChange handler is called', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/Switch/Can_be_disabled.feature b/cypress/integration/Switch/Can_be_disabled.feature new file mode 100644 index 0000000000..02dd13b441 --- /dev/null +++ b/cypress/integration/Switch/Can_be_disabled.feature @@ -0,0 +1,6 @@ +Feature: The Switch can be disabled + + Scenario: The user clicks a disabled Switch + Given a disabled Switch is rendered + When the user clicks the Switch + Then the Switch is not focused diff --git a/cypress/integration/Switch/Can_be_disabled/index.js b/cypress/integration/Switch/Can_be_disabled/index.js new file mode 100644 index 0000000000..058e1770cc --- /dev/null +++ b/cypress/integration/Switch/Can_be_disabled/index.js @@ -0,0 +1,13 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a disabled Switch is rendered', () => { + cy.visitStory('Switch', 'With disabled') +}) + +When('the user clicks the Switch', () => { + cy.get('[data-test="dhis2-uicore-switch"] input').click({ force: true }) +}) + +Then('the Switch is not focused', () => { + cy.focused().should('not.exist') +}) diff --git a/cypress/integration/Switch/Can_be_focused.feature b/cypress/integration/Switch/Can_be_focused.feature new file mode 100644 index 0000000000..a7fd0cc38d --- /dev/null +++ b/cypress/integration/Switch/Can_be_focused.feature @@ -0,0 +1,6 @@ +Feature: The Switch has an onFocus api + + Scenario: The user focuses the Switch + Given a Switch with onFocus handler is rendered + When the Switch is focused + Then the onFocus handler is called diff --git a/cypress/integration/Switch/Can_be_focused/index.js b/cypress/integration/Switch/Can_be_focused/index.js new file mode 100644 index 0000000000..ede990b4c1 --- /dev/null +++ b/cypress/integration/Switch/Can_be_focused/index.js @@ -0,0 +1,19 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Switch with onFocus handler is rendered', () => { + cy.visitStory('Switch', 'With onFocus') +}) + +When('the Switch is focused', () => { + cy.get('[data-test="dhis2-uicore-switch"] input').focus() +}) + +Then('the onFocus handler is called', () => { + cy.window().then(win => { + expect(win.onFocus).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/SwitchField/Can_be_required.feature b/cypress/integration/SwitchField/Can_be_required.feature new file mode 100644 index 0000000000..3008c01d92 --- /dev/null +++ b/cypress/integration/SwitchField/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the SwitchField + + Scenario: Rendering a SwitchField that is required + Given a SwitchField with label and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/SwitchField/Can_be_required/index.js b/cypress/integration/SwitchField/Can_be_required/index.js new file mode 100644 index 0000000000..afc1691a16 --- /dev/null +++ b/cypress/integration/SwitchField/Can_be_required/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a SwitchField with label and a required flag is rendered', () => { + cy.visitStory('SwitchField', 'With label and required') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-switchfield-required"]').should( + 'be.visible' + ) +}) diff --git a/cypress/integration/Tab/Accepts_children.feature b/cypress/integration/Tab/Accepts_children.feature new file mode 100644 index 0000000000..9b81dd913a --- /dev/null +++ b/cypress/integration/Tab/Accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Tab renders children + + Scenario: A Tab with children + Given a Tab with children is rendered + Then the children are visible diff --git a/cypress/integration/Tab/Accepts_children/index.js b/cypress/integration/Tab/Accepts_children/index.js new file mode 100644 index 0000000000..f4c5b00464 --- /dev/null +++ b/cypress/integration/Tab/Accepts_children/index.js @@ -0,0 +1,12 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Tab with children is rendered', () => { + cy.visitStory('Tab', 'With children') + cy.get('[data-test="dhis2-uicore-tab"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.get('[data-test="dhis2-uicore-tab"]') + .contains('I am a child') + .should('be.visible') +}) diff --git a/cypress/integration/Tab/Accepts_icon.feature b/cypress/integration/Tab/Accepts_icon.feature new file mode 100644 index 0000000000..3e37d1b767 --- /dev/null +++ b/cypress/integration/Tab/Accepts_icon.feature @@ -0,0 +1,5 @@ +Feature: The Tab renders icon + + Scenario: A Tab with an icon + Given a Tab with an icon is rendered + Then the icon is visible diff --git a/cypress/integration/Tab/Accepts_icon/index.js b/cypress/integration/Tab/Accepts_icon/index.js new file mode 100644 index 0000000000..ea0c993670 --- /dev/null +++ b/cypress/integration/Tab/Accepts_icon/index.js @@ -0,0 +1,12 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Tab with an icon is rendered', () => { + cy.visitStory('Tab', 'With icon') + cy.get('[data-test="dhis2-uicore-tab"]').should('be.visible') +}) + +Then('the icon is visible', () => { + cy.get('[data-test="dhis2-uicore-tab"]') + .contains('Icon') + .should('be.visible') +}) diff --git a/cypress/integration/Tab/Is_clickable.feature b/cypress/integration/Tab/Is_clickable.feature new file mode 100644 index 0000000000..926c8c8a56 --- /dev/null +++ b/cypress/integration/Tab/Is_clickable.feature @@ -0,0 +1,11 @@ +Feature: The Tab has an onClick api + + Scenario: The user clicks on the Tab + Given a Tab with onClick handler is rendered + When the Tab is clicked + Then the onClick handler is called + + Scenario: The user clicks on a disabled Tab + Given a disabled Tab with onClick handler is rendered + When the Tab is clicked + Then the onClick handler is not called diff --git a/cypress/integration/Tab/Is_clickable/index.js b/cypress/integration/Tab/Is_clickable/index.js new file mode 100644 index 0000000000..7901acc28a --- /dev/null +++ b/cypress/integration/Tab/Is_clickable/index.js @@ -0,0 +1,25 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Tab with onClick handler is rendered', () => { + cy.visitStory('Tab', 'With onClick') +}) + +Given('a disabled Tab with onClick handler is rendered', () => { + cy.visitStory('Tab', 'With onClick and disabled') +}) + +When('the Tab is clicked', () => { + cy.get('[data-test="dhis2-uicore-tab"]').click() +}) + +Then('the onClick handler is called', () => { + cy.window().then(win => { + expect(win.onClick).to.be.calledWith({}) + }) +}) + +Then('the onClick handler is not called', () => { + cy.window().then(win => { + expect(win.onClick).not.to.be.called + }) +}) diff --git a/cypress/integration/TabBar/Accepts_children.feature b/cypress/integration/TabBar/Accepts_children.feature new file mode 100644 index 0000000000..acb55b4001 --- /dev/null +++ b/cypress/integration/TabBar/Accepts_children.feature @@ -0,0 +1,9 @@ +Feature: The TabBar renders children + + Scenario: A TabBar with children + Given a TabBar with children is rendered + Then the children are visible + + Scenario: A scrollable TabBar with children + Given a scrollable TabBar with children is rendered + Then the children are visible diff --git a/cypress/integration/TabBar/Accepts_children/index.js b/cypress/integration/TabBar/Accepts_children/index.js new file mode 100644 index 0000000000..141b72d405 --- /dev/null +++ b/cypress/integration/TabBar/Accepts_children/index.js @@ -0,0 +1,17 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a TabBar with children is rendered', () => { + cy.visitStory('TabBar', 'With children') + cy.get('[data-test="dhis2-uicore-tabbar"]').should('be.visible') +}) + +Given('a scrollable TabBar with children is rendered', () => { + cy.visitStory('TabBar', 'Scrollable with children') + cy.get('[data-test="dhis2-uicore-tabbar"]').should('be.visible') +}) + +Then('the children are visible', () => { + cy.get('[data-test="dhis2-uicore-tabbar"]') + .contains('I am a child') + .should('be.visible') +}) diff --git a/cypress/integration/Tag/Accepts_icon.feature b/cypress/integration/Tag/Accepts_icon.feature new file mode 100644 index 0000000000..04e262caf7 --- /dev/null +++ b/cypress/integration/Tag/Accepts_icon.feature @@ -0,0 +1,9 @@ +Feature: The Tag accepts an icon prop + + Scenario: Tag has no icon by default + Given a default Tag is rendered + Then the icon will not be visible + + Scenario: Tag with icon + Given a Tag with an icon is rendered + Then the icon will be visible diff --git a/cypress/integration/Tag/Accepts_icon/index.js b/cypress/integration/Tag/Accepts_icon/index.js new file mode 100644 index 0000000000..a425c3c721 --- /dev/null +++ b/cypress/integration/Tag/Accepts_icon/index.js @@ -0,0 +1,21 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default Tag is rendered', () => { + cy.visitStory('Tag', 'Without icon') + cy.get('[data-test="dhis2-uicore-tag"]').should('be.visible') +}) + +Given('a Tag with an icon is rendered', () => { + cy.visitStory('Tag', 'With icon') + cy.get('[data-test="dhis2-uicore-tag"]').should('be.visible') +}) + +Then('the icon will not be visible', () => { + cy.get('[data-test="dhis2-uicore-tag-icon"]').should('not.be.visible') +}) + +Then('the icon will be visible', () => { + cy.get('[data-test="dhis2-uicore-tag-icon"]') + .contains('Icon') + .should('be.visible') +}) diff --git a/cypress/integration/Tag/Accepts_text.feature b/cypress/integration/Tag/Accepts_text.feature new file mode 100644 index 0000000000..8059e6c4d6 --- /dev/null +++ b/cypress/integration/Tag/Accepts_text.feature @@ -0,0 +1,5 @@ +Feature: The Tag displays text + + Scenario: Standard Tag with text + Given a Tag with text is rendered + Then the text will be visible diff --git a/cypress/integration/Tag/Accepts_text/index.js b/cypress/integration/Tag/Accepts_text/index.js new file mode 100644 index 0000000000..f87d9aed99 --- /dev/null +++ b/cypress/integration/Tag/Accepts_text/index.js @@ -0,0 +1,12 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Tag with text is rendered', () => { + cy.visitStory('Tag', 'With text') + cy.get('[data-test="dhis2-uicore-tag"]').should('be.visible') +}) + +Then('the text will be visible', () => { + cy.get('[data-test="dhis2-uicore-tag-text"]') + .contains('Text content') + .should('be.visible') +}) diff --git a/cypress/integration/TextArea/Accepts_initial_focus.feature b/cypress/integration/TextArea/Accepts_initial_focus.feature new file mode 100644 index 0000000000..8d6ce40c1b --- /dev/null +++ b/cypress/integration/TextArea/Accepts_initial_focus.feature @@ -0,0 +1,5 @@ +Feature: Focusing the TextArea on mount + + Scenario: The TextArea renders with focus + Given a TextArea with initialFocus is rendered + Then the TextArea is focused diff --git a/cypress/integration/TextArea/Accepts_initial_focus/index.js b/cypress/integration/TextArea/Accepts_initial_focus/index.js new file mode 100644 index 0000000000..efdd39173c --- /dev/null +++ b/cypress/integration/TextArea/Accepts_initial_focus/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a TextArea with initialFocus is rendered', () => { + cy.visitStory('TextArea', 'With initialFocus') +}) + +Then('the TextArea is focused', () => { + cy.focused() + .parent('[data-test="dhis2-uicore-textarea"]') + .should('exist') +}) diff --git a/cypress/integration/TextArea/Can_be_blurred.feature b/cypress/integration/TextArea/Can_be_blurred.feature new file mode 100644 index 0000000000..fc26af695d --- /dev/null +++ b/cypress/integration/TextArea/Can_be_blurred.feature @@ -0,0 +1,6 @@ +Feature: The TextArea has an onBlur api + + Scenario: The user blurs the TextArea + Given a TextArea with initialFocus and onBlur handler is rendered + When the TextArea is blurred + Then the onBlur handler is called diff --git a/cypress/integration/TextArea/Can_be_blurred/index.js b/cypress/integration/TextArea/Can_be_blurred/index.js new file mode 100644 index 0000000000..5093a54ddb --- /dev/null +++ b/cypress/integration/TextArea/Can_be_blurred/index.js @@ -0,0 +1,18 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a TextArea with initialFocus and onBlur handler is rendered', () => { + cy.visitStory('TextArea', 'With initialFocus and onBlur') +}) + +When('the TextArea is blurred', () => { + cy.get('[data-test="dhis2-uicore-textarea"] textarea').blur() +}) + +Then('the onBlur handler is called', () => { + cy.window().then(win => { + expect(win.onBlur).to.be.calledWith({ + value: '', + name: 'textarea', + }) + }) +}) diff --git a/cypress/integration/TextArea/Can_be_changed.feature b/cypress/integration/TextArea/Can_be_changed.feature new file mode 100644 index 0000000000..42591c40b7 --- /dev/null +++ b/cypress/integration/TextArea/Can_be_changed.feature @@ -0,0 +1,6 @@ +Feature: The TextArea has an onChange api + + Scenario: The user types a character into the TextArea + Given a TextArea with onChange handler is rendered + When the TextArea is filled with a character + Then the onChange handler is called diff --git a/cypress/integration/TextArea/Can_be_changed/index.js b/cypress/integration/TextArea/Can_be_changed/index.js new file mode 100644 index 0000000000..e3bc1ab540 --- /dev/null +++ b/cypress/integration/TextArea/Can_be_changed/index.js @@ -0,0 +1,21 @@ +import '../common/index' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a TextArea with onChange handler is rendered', () => { + cy.visitStory('TextArea', 'With onChange') +}) + +When('the TextArea is filled with a character', () => { + cy.get('[data-test="dhis2-uicore-textarea"]') + .click() + .type('a') +}) + +Then('the onChange handler is called', () => { + cy.window().then(win => { + expect(win.onChange).to.be.calledWith({ + value: 'a', + name: 'textarea', + }) + }) +}) diff --git a/cypress/integration/TextArea/Can_be_disabled.feature b/cypress/integration/TextArea/Can_be_disabled.feature new file mode 100644 index 0000000000..e6993449e3 --- /dev/null +++ b/cypress/integration/TextArea/Can_be_disabled.feature @@ -0,0 +1,6 @@ +Feature: The TextArea can be disabled + + Scenario: The user clicks a disabled TextArea + Given a disabled TextArea is rendered + When the user clicks the TextArea + Then the TextArea is not focused diff --git a/cypress/integration/TextArea/Can_be_disabled/index.js b/cypress/integration/TextArea/Can_be_disabled/index.js new file mode 100644 index 0000000000..00da953f31 --- /dev/null +++ b/cypress/integration/TextArea/Can_be_disabled/index.js @@ -0,0 +1,15 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a disabled TextArea is rendered', () => { + cy.visitStory('TextArea', 'With disabled') +}) + +When('the user clicks the TextArea', () => { + cy.get('[data-test="dhis2-uicore-textarea"] textarea').click({ + force: true, + }) +}) + +Then('the TextArea is not focused', () => { + cy.focused().should('not.exist') +}) diff --git a/cypress/integration/TextArea/Can_be_focused.feature b/cypress/integration/TextArea/Can_be_focused.feature new file mode 100644 index 0000000000..4fb07aa089 --- /dev/null +++ b/cypress/integration/TextArea/Can_be_focused.feature @@ -0,0 +1,6 @@ +Feature: The TextArea has an onFocus api + + Scenario: The user focuses the TextArea + Given a TextArea with onFocus handler is rendered + When the TextArea is focused + Then the onFocus handler is called diff --git a/cypress/integration/TextArea/Can_be_focused/index.js b/cypress/integration/TextArea/Can_be_focused/index.js new file mode 100644 index 0000000000..26d24adf72 --- /dev/null +++ b/cypress/integration/TextArea/Can_be_focused/index.js @@ -0,0 +1,19 @@ +import '../common/index' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a TextArea with onFocus handler is rendered', () => { + cy.visitStory('TextArea', 'With onFocus') +}) + +When('the TextArea is focused', () => { + cy.get('[data-test="dhis2-uicore-textarea"] textarea').focus() +}) + +Then('the onFocus handler is called', () => { + cy.window().then(win => { + expect(win.onFocus).to.be.calledWith({ + value: '', + name: 'textarea', + }) + }) +}) diff --git a/cypress/integration/TextArea/common/index.js b/cypress/integration/TextArea/common/index.js new file mode 100644 index 0000000000..d376304d6b --- /dev/null +++ b/cypress/integration/TextArea/common/index.js @@ -0,0 +1,5 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given('an empty TextArea is rendered', () => { + cy.visitStory('TextArea', 'Empty') +}) diff --git a/cypress/integration/TextAreaField/Can_be_required.feature b/cypress/integration/TextAreaField/Can_be_required.feature new file mode 100644 index 0000000000..a6486bb929 --- /dev/null +++ b/cypress/integration/TextAreaField/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the TextAreaField + + Scenario: Rendering a TextAreaField that is required + Given a TextAreaField with label and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/TextAreaField/Can_be_required/index.js b/cypress/integration/TextAreaField/Can_be_required/index.js new file mode 100644 index 0000000000..a8390a296e --- /dev/null +++ b/cypress/integration/TextAreaField/Can_be_required/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a TextAreaField with label and a required flag is rendered', () => { + cy.visitStory('TextAreaField', 'With label and required') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-label-required"]').should('be.visible') +}) diff --git a/cypress/integration/ToggleGroupField/Can_be_required.feature b/cypress/integration/ToggleGroupField/Can_be_required.feature new file mode 100644 index 0000000000..bb4c567698 --- /dev/null +++ b/cypress/integration/ToggleGroupField/Can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the ToggleGroupField + + Scenario: Rendering a ToggleGroupField that is required + Given a ToggleGroupField with label and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/ToggleGroupField/Can_be_required/index.js b/cypress/integration/ToggleGroupField/Can_be_required/index.js new file mode 100644 index 0000000000..50ca23b7d1 --- /dev/null +++ b/cypress/integration/ToggleGroupField/Can_be_required/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a ToggleGroupField with label and a required flag is rendered', () => { + cy.visitStory('ToggleGroupField', 'With label and required') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-legend-required"]').should('be.visible') +}) diff --git a/cypress/integration/forms/Checkbox/Can_toggle_a_boolean.feature b/cypress/integration/forms/Checkbox/Can_toggle_a_boolean.feature new file mode 100644 index 0000000000..9e739596d5 --- /dev/null +++ b/cypress/integration/forms/Checkbox/Can_toggle_a_boolean.feature @@ -0,0 +1,11 @@ +Feature: The Checkbox can toggle a boolean + + Scenario: The user checks the Checkbox + Given an unchecked Checkbox without value is rendered + When the user clicks on the Checkbox + Then the form value that corresponds to the checkbox will be true + + Scenario: The user unchecks the Checkbox + Given a checked Checkbox without value is rendered + When the user clicks on the Checkbox + Then the form value that corresponds to the checkbox will be false diff --git a/cypress/integration/forms/Checkbox/Can_toggle_a_boolean/index.js b/cypress/integration/forms/Checkbox/Can_toggle_a_boolean/index.js new file mode 100644 index 0000000000..d67d1402c4 --- /dev/null +++ b/cypress/integration/forms/Checkbox/Can_toggle_a_boolean/index.js @@ -0,0 +1,20 @@ +import '../common/index.js' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an unchecked Checkbox without value is rendered', () => { + cy.visitStory('Testing:Checkbox', 'Unchecked') + cy.verifyFormValue('checkbox', undefined) +}) + +Then('the form value that corresponds to the checkbox will be true', () => { + cy.verifyFormValue('checkbox', true) +}) + +Given('a checked Checkbox without value is rendered', () => { + cy.visitStory('Testing:Checkbox', 'Checked') + cy.verifyFormValue('checkbox', true) +}) + +Then('the form value that corresponds to the checkbox will be false', () => { + cy.verifyFormValue('checkbox', false) +}) diff --git a/cypress/integration/forms/Checkbox/Can_toggle_a_value.feature b/cypress/integration/forms/Checkbox/Can_toggle_a_value.feature new file mode 100644 index 0000000000..88c93bcab5 --- /dev/null +++ b/cypress/integration/forms/Checkbox/Can_toggle_a_value.feature @@ -0,0 +1,11 @@ +Feature: The Checkbox can toggle a value + + Scenario: The user clicks on the Checkbox + Given an unchecked Checkbox with a value is rendered + When the user clicks on the Checkbox + Then the form value that corresponds to the checkbox will be yes + + Scenario: The user clicks on the Checkbox + Given a checked Checkbox with a value is rendered + When the user clicks on the Checkbox + Then the form value that corresponds to the checkbox will be null diff --git a/cypress/integration/forms/Checkbox/Can_toggle_a_value/index.js b/cypress/integration/forms/Checkbox/Can_toggle_a_value/index.js new file mode 100644 index 0000000000..9aff5a6a89 --- /dev/null +++ b/cypress/integration/forms/Checkbox/Can_toggle_a_value/index.js @@ -0,0 +1,22 @@ +import '../common/index.js' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an unchecked Checkbox with a value is rendered', () => { + cy.visitStory('Testing:Checkbox', 'Unchecked with value') + cy.verifyFormValue('checkbox', undefined) +}) + +Then('the form value that corresponds to the checkbox will be yes', () => { + cy.verifyFormValue('checkbox', 'yes') +}) + +Given('a checked Checkbox with a value is rendered', () => { + cy.visitStory('Testing:Checkbox', 'Checked with value') + cy.verifyFormValue('checkbox', 'yes') +}) + +Then('the form value that corresponds to the checkbox will be null', () => { + cy.window().then(win => { + expect(win.formValues.checkbox).to.equal(null) + }) +}) diff --git a/cypress/integration/forms/Checkbox/Displays_error.feature b/cypress/integration/forms/Checkbox/Displays_error.feature new file mode 100644 index 0000000000..e1977c5aed --- /dev/null +++ b/cypress/integration/forms/Checkbox/Displays_error.feature @@ -0,0 +1,6 @@ +Feature: The Checkbox field displays an error when invalid + + Scenario: The user submits a form with an unchecked required checkbox + Given an unchecked Checkbox is rendered + When the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/Checkbox/Displays_error/index.js b/cypress/integration/forms/Checkbox/Displays_error/index.js new file mode 100644 index 0000000000..fb04e50f62 --- /dev/null +++ b/cypress/integration/forms/Checkbox/Displays_error/index.js @@ -0,0 +1,12 @@ +import '../../common/submit.js' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' +import { hasValueMessage } from '../../../../../packages/forms/src/validators/hasValue.js' + +Given('an unchecked Checkbox is rendered', () => { + cy.visitStory('Testing:Checkbox', 'Unchecked') + cy.verifyFormValue('checkbox', undefined) +}) + +Then('an error message is shown', () => { + cy.get('.checkbox .error').should('contain', hasValueMessage) +}) diff --git a/cypress/integration/forms/Checkbox/common/index.js b/cypress/integration/forms/Checkbox/common/index.js new file mode 100644 index 0000000000..8050d769ff --- /dev/null +++ b/cypress/integration/forms/Checkbox/common/index.js @@ -0,0 +1,5 @@ +import { When } from 'cypress-cucumber-preprocessor/steps' + +When('the user clicks on the Checkbox', () => { + cy.get('.checkbox label').click() +}) diff --git a/cypress/integration/forms/CheckboxGroup/Can_set_a_value.feature b/cypress/integration/forms/CheckboxGroup/Can_set_a_value.feature new file mode 100644 index 0000000000..4a978955bc --- /dev/null +++ b/cypress/integration/forms/CheckboxGroup/Can_set_a_value.feature @@ -0,0 +1,14 @@ +Feature: The CheckboxGroup can set a value + + Scenario: The user selects one option + Given a required CheckboxGroup with no selected value + And the CheckboxGroup has two options + When the user selects the first option + Then the form state's value equals the first option's value + + Scenario: The user selects two options + Given a required CheckboxGroup with no selected value + And the CheckboxGroup has two options + When the user selects the first option + And the user selects the second option + Then the form state's value contains both options diff --git a/cypress/integration/forms/CheckboxGroup/Can_set_a_value/index.js b/cypress/integration/forms/CheckboxGroup/Can_set_a_value/index.js new file mode 100644 index 0000000000..2fdfa5d844 --- /dev/null +++ b/cypress/integration/forms/CheckboxGroup/Can_set_a_value/index.js @@ -0,0 +1,42 @@ +import '../common/index.js' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the CheckboxGroup has two options', () => { + const options = [ + { value: 'value1', label: 'Label 1' }, + { value: 'value2', label: 'Label 2' }, + ] + + cy.wrap(options).as('options') + cy.window().then(win => { + win.updateCypressProps({ options }) + }) +}) + +When('the user selects the first option', () => { + cy.get('label:first').click() +}) + +When('the user selects the second option', () => { + cy.get('label:last').click() +}) + +Then("the form state's value equals the first option's value", () => { + cy.get('@options').then(options => { + const [firstOption] = options + cy.getFormValue('choice').then(selected => { + expect(selected).to.have.lengthOf(1) + expect(selected).to.include.members([firstOption.value]) + }) + }) +}) + +Then("the form state's value contains both options", () => { + cy.get('@options').then(options => { + const allOptions = options.map(({ value }) => value) + cy.getFormValue('choice').then(selected => { + expect(selected).to.have.lengthOf(allOptions.length) + expect(selected).to.include.members(allOptions) + }) + }) +}) diff --git a/cypress/integration/forms/CheckboxGroup/Displays_error.feature b/cypress/integration/forms/CheckboxGroup/Displays_error.feature new file mode 100644 index 0000000000..fddb9828cb --- /dev/null +++ b/cypress/integration/forms/CheckboxGroup/Displays_error.feature @@ -0,0 +1,6 @@ +Feature: The CheckboxGroup field displays an error when invalid + + Scenario: Form is submitted with none of the options selected + Given a required CheckboxGroup with no selected value + When the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/CheckboxGroup/Displays_error/index.js b/cypress/integration/forms/CheckboxGroup/Displays_error/index.js new file mode 100644 index 0000000000..1836888f28 --- /dev/null +++ b/cypress/integration/forms/CheckboxGroup/Displays_error/index.js @@ -0,0 +1,8 @@ +import '../common/index.js' +import '../../common/submit.js' +import { Then } from 'cypress-cucumber-preprocessor/steps' +import { hasValueMessage } from '../../../../../packages/forms/src/validators/hasValue.js' + +Then('an error message is shown', () => { + cy.get('.error').should('contain', hasValueMessage) +}) diff --git a/cypress/integration/forms/CheckboxGroup/common/index.js b/cypress/integration/forms/CheckboxGroup/common/index.js new file mode 100644 index 0000000000..2a8d2def0d --- /dev/null +++ b/cypress/integration/forms/CheckboxGroup/common/index.js @@ -0,0 +1,6 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given('a required CheckboxGroup with no selected value', () => { + cy.visitStory('Testing:CheckboxGroup', 'Required') + cy.verifyFormValue('choice', undefined) +}) diff --git a/cypress/integration/forms/FileInput/Accepts_file.feature b/cypress/integration/forms/FileInput/Accepts_file.feature new file mode 100644 index 0000000000..3fcf55c15f --- /dev/null +++ b/cypress/integration/forms/FileInput/Accepts_file.feature @@ -0,0 +1,13 @@ +Feature: The FileInput accepts files + + Scenario: The user provides a file + Given a single-file FileInput is rendered + And the InputField does not contain any files + When a file is provided + Then the form state contains that file + + Scenario: The user provides multiple files + Given a multi-file IputField is rendered + And the InputField does not contain any files + When more than one files are provided + Then the form state contains those files diff --git a/cypress/integration/forms/FileInput/Accepts_file/index.js b/cypress/integration/forms/FileInput/Accepts_file/index.js new file mode 100644 index 0000000000..d45aa75142 --- /dev/null +++ b/cypress/integration/forms/FileInput/Accepts_file/index.js @@ -0,0 +1,41 @@ +import '../common/index.js' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a multi-file IputField is rendered', () => { + cy.visitStory('Testing:Forms', 'Standard form') +}) + +When('a file is provided', () => { + cy.get('[name="fileTxt"]').uploadSingleFile('txt', 'FileInput/file.txt') +}) + +When('more than one files are provided', () => { + cy.get('[name="fileJpgs"]').uploadMultipleFiles( + [ + { fileType: 'txt', fixture: 'FileInput/file.txt' }, + { fileType: 'md', fixture: 'FileInput/file.md' }, + ], + true + ) +}) + +Then('the form state contains that file', () => { + cy.window().then(win => { + const { fileTxt } = win.formValues + expect(fileTxt).to.have.lengthOf(1) + + const [file] = fileTxt + expect(file.name).to.equal('file.txt') + }) +}) + +Then('the form state contains those files', () => { + cy.window().then(win => { + const { fileJpgs } = win.formValues + expect(fileJpgs).to.have.lengthOf(2) + + const [file1, file2] = fileJpgs + expect(file1.name).to.equal('file.txt') + expect(file2.name).to.equal('file.md') + }) +}) diff --git a/cypress/integration/forms/FileInput/Displays_error.feature b/cypress/integration/forms/FileInput/Displays_error.feature new file mode 100644 index 0000000000..dccca1819a --- /dev/null +++ b/cypress/integration/forms/FileInput/Displays_error.feature @@ -0,0 +1,8 @@ +Feature: The FileInput field displays an error when invalid + + Scenario: The user provides files with the wrong file type + Given a single-file FileInput is rendered + And the InputField does not contain any files + When a file with the wrong file type is provided + And the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/FileInput/Displays_error/index.js b/cypress/integration/forms/FileInput/Displays_error/index.js new file mode 100644 index 0000000000..e817c189c7 --- /dev/null +++ b/cypress/integration/forms/FileInput/Displays_error/index.js @@ -0,0 +1,14 @@ +import '../common/index.js' +import '../../common/submit.js' +import { When, Then } from 'cypress-cucumber-preprocessor/steps' + +When('a file with the wrong file type is provided', () => { + cy.get('[name="fileTxt"]').uploadSingleFile('md', 'FileInput/file.md') +}) + +Then('an error message is shown', () => { + cy.get('.fileTxt .error').should( + 'contain', + 'The file you provided is not a txt file, received "md"' + ) +}) diff --git a/cypress/integration/forms/FileInput/common/index.js b/cypress/integration/forms/FileInput/common/index.js new file mode 100644 index 0000000000..e79f1771fa --- /dev/null +++ b/cypress/integration/forms/FileInput/common/index.js @@ -0,0 +1,9 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given('a single-file FileInput is rendered', () => { + cy.visitStory('Testing:Forms', 'Standard form') +}) + +Given('the InputField does not contain any files', () => { + cy.verifyFormValue('fileTxt', undefined) +}) diff --git a/cypress/integration/forms/Input/Can_set_a_value.feature b/cypress/integration/forms/Input/Can_set_a_value.feature new file mode 100644 index 0000000000..6dc317bdbb --- /dev/null +++ b/cypress/integration/forms/Input/Can_set_a_value.feature @@ -0,0 +1,6 @@ +Feature: The Input can set a value + + Scenario: The user types some text + Given a Input with no text is rendered + When the user types something in the Input + Then the form state's value equals the written text diff --git a/cypress/integration/forms/Input/Can_set_a_value/index.js b/cypress/integration/forms/Input/Can_set_a_value/index.js new file mode 100644 index 0000000000..7f17b59532 --- /dev/null +++ b/cypress/integration/forms/Input/Can_set_a_value/index.js @@ -0,0 +1,14 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Input with no text is rendered', () => { + cy.visitStory('Testing:InputControl', 'Default') + cy.verifyFormValue('agree', undefined) +}) + +When('the user types something in the Input', () => { + cy.get('input').type('something') +}) + +Then("the form state's value equals the written text", () => { + cy.verifyFormValue('agree', 'something') +}) diff --git a/cypress/integration/forms/Input/Displays_error.feature b/cypress/integration/forms/Input/Displays_error.feature new file mode 100644 index 0000000000..c9b6515fe4 --- /dev/null +++ b/cypress/integration/forms/Input/Displays_error.feature @@ -0,0 +1,6 @@ +Feature: The Input field displays an error when invalid + + Scenario: Form is submitted with empty input + Given an empty, required Input is rendered + When the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/Input/Displays_error/index.js b/cypress/integration/forms/Input/Displays_error/index.js new file mode 100644 index 0000000000..e4d6d016a7 --- /dev/null +++ b/cypress/integration/forms/Input/Displays_error/index.js @@ -0,0 +1,12 @@ +import '../../common/submit.js' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' +import { hasValueMessage } from '../../../../../packages/forms/src/validators/hasValue.js' + +Given('an empty, required Input is rendered', () => { + cy.visitStory('Testing:InputControl', 'Required') + cy.verifyFormValue('agree', undefined) +}) + +Then('an error message is shown', () => { + cy.get('.error').should('contain', hasValueMessage) +}) diff --git a/cypress/integration/forms/MultiSelect/Can_set_a_value.feature b/cypress/integration/forms/MultiSelect/Can_set_a_value.feature new file mode 100644 index 0000000000..4111f14519 --- /dev/null +++ b/cypress/integration/forms/MultiSelect/Can_set_a_value.feature @@ -0,0 +1,14 @@ +Feature: The MultiSelect can set a value + + Scenario: The user selects one option + Given a required MultiSelect with no selected value + And the MultiSelect has two options + When the user selects the first option + Then the form state's value equals the first option's value + + Scenario: The user selects two options + Given a required MultiSelect with no selected value + And the MultiSelect has two options + When the user selects the first option + And the user selects the second option + Then the form state's value contains both options diff --git a/cypress/integration/forms/MultiSelect/Can_set_a_value/index.js b/cypress/integration/forms/MultiSelect/Can_set_a_value/index.js new file mode 100644 index 0000000000..f0fd2df5a1 --- /dev/null +++ b/cypress/integration/forms/MultiSelect/Can_set_a_value/index.js @@ -0,0 +1,41 @@ +import '../common/index.js' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the MultiSelect has two options', () => { + const options = [ + { value: 'value1', label: 'Label 1' }, + { value: 'value2', label: 'Label 2' }, + ] + + cy.wrap(options).as('options') + cy.window().then(win => { + win.updateCypressProps({ options }) + }) +}) + +When('the user selects the first option', () => { + cy.get('form > div').selectSelectNthOption(1, true) +}) + +When('the user selects the second option', () => { + cy.get('form > div').selectSelectNthOption(2, true) +}) + +Then("the form state's value equals the first option's value", () => { + cy.get('@options').then(options => { + const [firstOption] = options + cy.getFormValue('multiSelect').then(selected => { + expect(selected).to.have.lengthOf(1) + expect(selected).to.deep.equal([firstOption]) + }) + }) +}) + +Then("the form state's value contains both options", () => { + cy.get('@options').then(options => { + cy.getFormValue('multiSelect').then(selected => { + expect(selected).to.have.lengthOf(options.length) + expect(selected).to.deep.equal(options) + }) + }) +}) diff --git a/cypress/integration/forms/MultiSelect/Displays_error.feature b/cypress/integration/forms/MultiSelect/Displays_error.feature new file mode 100644 index 0000000000..b37ae2f960 --- /dev/null +++ b/cypress/integration/forms/MultiSelect/Displays_error.feature @@ -0,0 +1,6 @@ +Feature: The MultiSelect field displays an error when invalid + + Scenario: Form is submitted with none of the options selected + Given a required MultiSelect with no selected value + When the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/MultiSelect/Displays_error/index.js b/cypress/integration/forms/MultiSelect/Displays_error/index.js new file mode 100644 index 0000000000..1836888f28 --- /dev/null +++ b/cypress/integration/forms/MultiSelect/Displays_error/index.js @@ -0,0 +1,8 @@ +import '../common/index.js' +import '../../common/submit.js' +import { Then } from 'cypress-cucumber-preprocessor/steps' +import { hasValueMessage } from '../../../../../packages/forms/src/validators/hasValue.js' + +Then('an error message is shown', () => { + cy.get('.error').should('contain', hasValueMessage) +}) diff --git a/cypress/integration/forms/MultiSelect/common/index.js b/cypress/integration/forms/MultiSelect/common/index.js new file mode 100644 index 0000000000..a8fec55edc --- /dev/null +++ b/cypress/integration/forms/MultiSelect/common/index.js @@ -0,0 +1,7 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given('a required MultiSelect with no selected value', () => { + cy.visitStory('Testing:MultiSelectControl', 'Required') + cy.getFormValue('multiSelect') + cy.verifyFormValue('multiSelect', undefined) +}) diff --git a/cypress/integration/forms/RadioGroup/Can_set_a_value.feature b/cypress/integration/forms/RadioGroup/Can_set_a_value.feature new file mode 100644 index 0000000000..c9c5fdd087 --- /dev/null +++ b/cypress/integration/forms/RadioGroup/Can_set_a_value.feature @@ -0,0 +1,7 @@ +Feature: The RadioGroup can set a value + + Scenario: The user clicks the first option + Given a required RadioGroup with no selected value + And the RadioGroup has two options + When the user selects the first option + Then the form state's value equals the first option's value diff --git a/cypress/integration/forms/RadioGroup/Can_set_a_value/index.js b/cypress/integration/forms/RadioGroup/Can_set_a_value/index.js new file mode 100644 index 0000000000..19cfe33113 --- /dev/null +++ b/cypress/integration/forms/RadioGroup/Can_set_a_value/index.js @@ -0,0 +1,24 @@ +import '../common/index.js' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the RadioGroup has two options', () => { + const options = [ + { value: 'value1', label: 'Label 1' }, + { value: 'value2', label: 'Label 2' }, + ] + + cy.wrap(options).as('options') + cy.window().then(win => { + win.updateCypressProps({ options }) + }) +}) + +When('the user selects the first option', () => { + cy.get('label:first').click() +}) + +Then("the form state's value equals the first option's value", () => { + cy.get('@options').then(options => { + cy.verifyFormValue('choice', options[0].value) + }) +}) diff --git a/cypress/integration/forms/RadioGroup/Displays_error.feature b/cypress/integration/forms/RadioGroup/Displays_error.feature new file mode 100644 index 0000000000..9fbb20bffa --- /dev/null +++ b/cypress/integration/forms/RadioGroup/Displays_error.feature @@ -0,0 +1,6 @@ +Feature: The RadioGroup field displays an error when invalid + + Scenario: Form is submitted with none of the options selected + Given a required RadioGroup with no selected value + When the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/RadioGroup/Displays_error/index.js b/cypress/integration/forms/RadioGroup/Displays_error/index.js new file mode 100644 index 0000000000..1836888f28 --- /dev/null +++ b/cypress/integration/forms/RadioGroup/Displays_error/index.js @@ -0,0 +1,8 @@ +import '../common/index.js' +import '../../common/submit.js' +import { Then } from 'cypress-cucumber-preprocessor/steps' +import { hasValueMessage } from '../../../../../packages/forms/src/validators/hasValue.js' + +Then('an error message is shown', () => { + cy.get('.error').should('contain', hasValueMessage) +}) diff --git a/cypress/integration/forms/RadioGroup/common/index.js b/cypress/integration/forms/RadioGroup/common/index.js new file mode 100644 index 0000000000..fd34f9e5a4 --- /dev/null +++ b/cypress/integration/forms/RadioGroup/common/index.js @@ -0,0 +1,6 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given('a required RadioGroup with no selected value', () => { + cy.visitStory('Testing:RadioGroupControl', 'Required') + cy.verifyFormValue('choice', undefined) +}) diff --git a/cypress/integration/forms/SingleSelect/Can_set_a_value.feature b/cypress/integration/forms/SingleSelect/Can_set_a_value.feature new file mode 100644 index 0000000000..03a7ef96e7 --- /dev/null +++ b/cypress/integration/forms/SingleSelect/Can_set_a_value.feature @@ -0,0 +1,7 @@ +Feature: The SingleSelect can set a value + + Scenario: The user clicks the first option + Given a required SingleSelect with no selected value + And the SingleSelect has one option + When the user selects the first option + Then the form state's value equals the first option's value diff --git a/cypress/integration/forms/SingleSelect/Can_set_a_value/index.js b/cypress/integration/forms/SingleSelect/Can_set_a_value/index.js new file mode 100644 index 0000000000..2b1b5302e4 --- /dev/null +++ b/cypress/integration/forms/SingleSelect/Can_set_a_value/index.js @@ -0,0 +1,23 @@ +import '../common/index.js' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the SingleSelect has one option', () => { + const options = [{ value: 'Value', label: 'Label' }] + + cy.wrap(options).as('options') + cy.window().then(win => { + win.updateCypressProps({ options }) + }) +}) + +When('the user selects the first option', () => { + cy.get('form > div').selectSelectNthOption(1) +}) + +Then("the form state's value equals the first option's value", () => { + cy.get('@options').then(options => { + cy.getFormValue('singleSelect').then(actualValue => { + expect(actualValue).to.deep.equal(options[0]) + }) + }) +}) diff --git a/cypress/integration/forms/SingleSelect/Displays_error.feature b/cypress/integration/forms/SingleSelect/Displays_error.feature new file mode 100644 index 0000000000..d10c7b9a7a --- /dev/null +++ b/cypress/integration/forms/SingleSelect/Displays_error.feature @@ -0,0 +1,6 @@ +Feature: The SingleSelect field displays an error when invalid + + Scenario: Form is submitted with none of the options selected + Given a required SingleSelect with no selected value + When the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/SingleSelect/Displays_error/index.js b/cypress/integration/forms/SingleSelect/Displays_error/index.js new file mode 100644 index 0000000000..1836888f28 --- /dev/null +++ b/cypress/integration/forms/SingleSelect/Displays_error/index.js @@ -0,0 +1,8 @@ +import '../common/index.js' +import '../../common/submit.js' +import { Then } from 'cypress-cucumber-preprocessor/steps' +import { hasValueMessage } from '../../../../../packages/forms/src/validators/hasValue.js' + +Then('an error message is shown', () => { + cy.get('.error').should('contain', hasValueMessage) +}) diff --git a/cypress/integration/forms/SingleSelect/common/index.js b/cypress/integration/forms/SingleSelect/common/index.js new file mode 100644 index 0000000000..55a5126622 --- /dev/null +++ b/cypress/integration/forms/SingleSelect/common/index.js @@ -0,0 +1,6 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given('a required SingleSelect with no selected value', () => { + cy.visitStory('Testing:SingleSelectControl', 'Required') + cy.verifyFormValue('singleSelect', undefined) +}) diff --git a/cypress/integration/forms/Switch/Can_toggle_a_boolean.feature b/cypress/integration/forms/Switch/Can_toggle_a_boolean.feature new file mode 100644 index 0000000000..4f875893c3 --- /dev/null +++ b/cypress/integration/forms/Switch/Can_toggle_a_boolean.feature @@ -0,0 +1,11 @@ +Feature: The Switch can toggle a value + + Scenario: The user enables the Switch + Given an unchecked Switch without value is rendered + When the user clicks on the Switch + Then the form value that corresponds to the switch will be true + + Scenario: The user disables the Switch + Given a checked Switch without value is rendered + When the user clicks on the Switch + Then the form value that corresponds to the switch will be false diff --git a/cypress/integration/forms/Switch/Can_toggle_a_boolean/index.js b/cypress/integration/forms/Switch/Can_toggle_a_boolean/index.js new file mode 100644 index 0000000000..1a95b9f0d4 --- /dev/null +++ b/cypress/integration/forms/Switch/Can_toggle_a_boolean/index.js @@ -0,0 +1,20 @@ +import '../common/index.js' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an unchecked Switch without value is rendered', () => { + cy.visitStory('Testing:SwitchControl', 'Unchecked') + cy.verifyFormValue('switch', undefined) +}) + +Then('the form value that corresponds to the switch will be true', () => { + cy.verifyFormValue('switch', true) +}) + +Given('a checked Switch without value is rendered', () => { + cy.visitStory('Testing:SwitchControl', 'Checked') + cy.verifyFormValue('switch', true) +}) + +Then('the form value that corresponds to the switch will be false', () => { + cy.verifyFormValue('switch', false) +}) diff --git a/cypress/integration/forms/Switch/Can_toggle_a_value.feature b/cypress/integration/forms/Switch/Can_toggle_a_value.feature new file mode 100644 index 0000000000..493307768e --- /dev/null +++ b/cypress/integration/forms/Switch/Can_toggle_a_value.feature @@ -0,0 +1,11 @@ +Feature: The Switch can toggle a value + + Scenario: The user enables the Switch + Given an unchecked Switch with a value is rendered + When the user clicks on the Switch + Then the form value that corresponds to the switch will be yes + + Scenario: The user disables the Switch + Given a checked Switch with a value is rendered + When the user clicks on the Switch + Then the form value that corresponds to the switch will be null diff --git a/cypress/integration/forms/Switch/Can_toggle_a_value/index.js b/cypress/integration/forms/Switch/Can_toggle_a_value/index.js new file mode 100644 index 0000000000..38d218791c --- /dev/null +++ b/cypress/integration/forms/Switch/Can_toggle_a_value/index.js @@ -0,0 +1,22 @@ +import '../common/index.js' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('an unchecked Switch with a value is rendered', () => { + cy.visitStory('Testing:SwitchControl', 'Unchecked with value') + cy.verifyFormValue('switch', undefined) +}) + +Then('the form value that corresponds to the switch will be yes', () => { + cy.verifyFormValue('switch', 'yes') +}) + +Given('a checked Switch with a value is rendered', () => { + cy.visitStory('Testing:SwitchControl', 'Checked with value') + cy.verifyFormValue('switch', 'yes') +}) + +Then('the form value that corresponds to the switch will be null', () => { + cy.window().then(win => { + expect(win.formValues.switch).to.equal(null) + }) +}) diff --git a/cypress/integration/forms/Switch/Displays_error.feature b/cypress/integration/forms/Switch/Displays_error.feature new file mode 100644 index 0000000000..45f317d746 --- /dev/null +++ b/cypress/integration/forms/Switch/Displays_error.feature @@ -0,0 +1,6 @@ +Feature: The Switch field displays an error when invalid + + Scenario: The user submits a form with an unchecked required switch + Given an unchecked Switch is rendered + When the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/Switch/Displays_error/index.js b/cypress/integration/forms/Switch/Displays_error/index.js new file mode 100644 index 0000000000..953fc68958 --- /dev/null +++ b/cypress/integration/forms/Switch/Displays_error/index.js @@ -0,0 +1,12 @@ +import '../../common/submit.js' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' +import { hasValueMessage } from '../../../../../packages/forms/src/validators/hasValue.js' + +Given('an unchecked Switch is rendered', () => { + cy.visitStory('Testing:SwitchControl', 'Unchecked') + cy.verifyFormValue('switch', undefined) +}) + +Then('an error message is shown', () => { + cy.get('.switch .error').should('contain', hasValueMessage) +}) diff --git a/cypress/integration/forms/Switch/common/index.js b/cypress/integration/forms/Switch/common/index.js new file mode 100644 index 0000000000..f26a8e643d --- /dev/null +++ b/cypress/integration/forms/Switch/common/index.js @@ -0,0 +1,5 @@ +import { When } from 'cypress-cucumber-preprocessor/steps' + +When('the user clicks on the Switch', () => { + cy.get('.switch label').click() +}) diff --git a/cypress/integration/forms/TextArea/Can_set_a_value.feature b/cypress/integration/forms/TextArea/Can_set_a_value.feature new file mode 100644 index 0000000000..3e96e6a991 --- /dev/null +++ b/cypress/integration/forms/TextArea/Can_set_a_value.feature @@ -0,0 +1,6 @@ +Feature: The TextArea can set a value + + Scenario: The user types some text + Given a TextArea with no text is rendered + When the user types something in the TextArea + Then the form state's value equals the written text diff --git a/cypress/integration/forms/TextArea/Can_set_a_value/index.js b/cypress/integration/forms/TextArea/Can_set_a_value/index.js new file mode 100644 index 0000000000..1ebdc266e2 --- /dev/null +++ b/cypress/integration/forms/TextArea/Can_set_a_value/index.js @@ -0,0 +1,14 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a TextArea with no text is rendered', () => { + cy.visitStory('TextArea', 'Default') + cy.verifyFormValue('comment', undefined) +}) + +When('the user types something in the TextArea', () => { + cy.get('textarea').type('something') +}) + +Then("the form state's value equals the written text", () => { + cy.verifyFormValue('comment', 'something') +}) diff --git a/cypress/integration/forms/TextArea/Displays_error.feature b/cypress/integration/forms/TextArea/Displays_error.feature new file mode 100644 index 0000000000..6a98f40135 --- /dev/null +++ b/cypress/integration/forms/TextArea/Displays_error.feature @@ -0,0 +1,6 @@ +Feature: The TextArea field displays an error when invalid + + Scenario: Form is submitted with empty input + Given an empty, required TextArea is rendered + When the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/TextArea/Displays_error/index.js b/cypress/integration/forms/TextArea/Displays_error/index.js new file mode 100644 index 0000000000..cfbb2427a9 --- /dev/null +++ b/cypress/integration/forms/TextArea/Displays_error/index.js @@ -0,0 +1,12 @@ +import '../../common/submit.js' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' +import { hasValueMessage } from '../../../../../packages/forms/src/validators/hasValue.js' + +Given('an empty, required TextArea is rendered', () => { + cy.visitStory('TextArea', 'Required') + cy.verifyFormValue('agree', undefined) +}) + +Then('an error message is shown', () => { + cy.get('.error').should('contain', hasValueMessage) +}) diff --git a/cypress/integration/forms/common/submit.js b/cypress/integration/forms/common/submit.js new file mode 100644 index 0000000000..74851b362f --- /dev/null +++ b/cypress/integration/forms/common/submit.js @@ -0,0 +1,5 @@ +import { When } from 'cypress-cucumber-preprocessor/steps' + +When('the user submits the form', () => { + cy.get('button[type="submit"]').click() +}) diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000000..9dfeda54eb --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,7 @@ +const plugins = require('@dhis2/cli-utils-cypress/plugins') + +module.exports = (on, config) => { + plugins(on, config) + + // Add additional plugins here +} diff --git a/cypress/support/getFormValue.js b/cypress/support/getFormValue.js new file mode 100644 index 0000000000..4cb7e344be --- /dev/null +++ b/cypress/support/getFormValue.js @@ -0,0 +1,11 @@ +Cypress.Commands.add('getFormValue', fieldName => { + // make sure form spy function ran + cy.get('.form-spy-internal') + + return cy.window().then(win => { + // unlike in vanilla js, instead of returning + // the next value, in cypress we have to wrap + // the value + cy.wrap(win.formValues[fieldName]) + }) +}) diff --git a/cypress/support/getPositionsBySelectors.js b/cypress/support/getPositionsBySelectors.js new file mode 100644 index 0000000000..dc693659ce --- /dev/null +++ b/cypress/support/getPositionsBySelectors.js @@ -0,0 +1,15 @@ +Cypress.Commands.add('getPositionsBySelectors', (...elements) => { + const promise = cy.wrap([], { log: false }) + + for (const element of elements) { + promise.then(arr => + cy + .get(element) + .then(jQueryInstance => + cy.wrap([...arr, jQueryInstance[0].getBoundingClientRect()]) + ) + ) + } + + return promise +}) diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 0000000000..7789b8abd2 --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,11 @@ +import '@dhis2/cli-utils-cypress/support' + +// Add additional support functions here +import './getPositionsBySelectors' +import './uploadMultipleFiles' +import './uploadSingleFile' +import './visitStory' +import './org_unit_tree/index' +import './getFormValue' +import './selectSelectNthOption' +import './verifyFormValue' diff --git a/cypress/support/org_unit_tree/closeOrgUnitNode.js b/cypress/support/org_unit_tree/closeOrgUnitNode.js new file mode 100644 index 0000000000..acca190e6d --- /dev/null +++ b/cypress/support/org_unit_tree/closeOrgUnitNode.js @@ -0,0 +1,12 @@ +const closeOrgUnitNode = subject => + cy + .wrap(subject, { log: false }) + .shouldBeAnOpenNode() + .toggleOrgUnitNode(false) + .shouldBeAClosedNode() + +Cypress.Commands.add( + 'closeOrgUnitNode', + { prevSubject: true }, + closeOrgUnitNode +) diff --git a/cypress/support/org_unit_tree/getOrgUnitByLabel.js b/cypress/support/org_unit_tree/getOrgUnitByLabel.js new file mode 100644 index 0000000000..1ef4e7147d --- /dev/null +++ b/cypress/support/org_unit_tree/getOrgUnitByLabel.js @@ -0,0 +1,13 @@ +const getOrgUnitByLabel = label => { + return cy + .get( + `[data-test="dhis2-uiwidgets-orgunittree-node-label"]:contains("${label}")`, + { log: true } + ) + .parents('[data-test="dhis2-uiwidgets-orgunittree-node"]', { + log: false, + }) + .first() +} + +Cypress.Commands.add('getOrgUnitByLabel', getOrgUnitByLabel) diff --git a/cypress/support/org_unit_tree/index.js b/cypress/support/org_unit_tree/index.js new file mode 100644 index 0000000000..1b88fd7a32 --- /dev/null +++ b/cypress/support/org_unit_tree/index.js @@ -0,0 +1,16 @@ +// needs to be first so other helpers have access to this one +// Cypress tests will fail otherwise +import './shouldBeAClosedNode' + +import './closeOrgUnitNode' +import './getOrgUnitByLabel' +import './openOrgUnitNode' +import './shouldBeAnOpenNode' +import './shouldBeAnOrgUnitNode' +import './shouldHaveChildNodes' +import './shouldNotHaveChildNodes' +import './toggleOrgUnitNode' +import './toggleOrgUnitNodeSelection' +import './shouldBeASelectedOrgUnitNode' +import './shouldNotBeASelectedOrgUnitNode' +import './shouldBeDoneLoading' diff --git a/cypress/support/org_unit_tree/openOrgUnitNode.js b/cypress/support/org_unit_tree/openOrgUnitNode.js new file mode 100644 index 0000000000..e7b98557c7 --- /dev/null +++ b/cypress/support/org_unit_tree/openOrgUnitNode.js @@ -0,0 +1,8 @@ +const openOrgUnitNode = subject => + cy + .wrap(subject, { log: false }) + .shouldBeAClosedNode() + .toggleOrgUnitNode() + .shouldBeAnOpenNode() + +Cypress.Commands.add('openOrgUnitNode', { prevSubject: true }, openOrgUnitNode) diff --git a/cypress/support/org_unit_tree/shouldBeAClosedNode.js b/cypress/support/org_unit_tree/shouldBeAClosedNode.js new file mode 100644 index 0000000000..6b7dc77149 --- /dev/null +++ b/cypress/support/org_unit_tree/shouldBeAClosedNode.js @@ -0,0 +1,21 @@ +const shouldBeAClosedNode = subject => { + cy.wrap(subject, { log: false }) + .shouldBeAnOrgUnitNode() + .find('[data-test="dhis2-uiwidgets-orgunittree-node-leaves"]', { + log: false, + }) + .children({ log: false }) + .then(child => { + expect( + child.is('[data-test="dhis2-uiwidgets-orgunittree-node"]') + ).to.equal(false) + }) + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'shouldBeAClosedNode', + { prevSubject: true }, + shouldBeAClosedNode +) diff --git a/cypress/support/org_unit_tree/shouldBeASelectedOrgUnitNode.js b/cypress/support/org_unit_tree/shouldBeASelectedOrgUnitNode.js new file mode 100644 index 0000000000..f7885d4b6a --- /dev/null +++ b/cypress/support/org_unit_tree/shouldBeASelectedOrgUnitNode.js @@ -0,0 +1,27 @@ +const shouldBeASelectedOrgUnitNode = (subject, singleSelection = false) => { + cy.wrap(subject, { log: false }) + .shouldBeAnOrgUnitNode() + .find('[data-test="dhis2-uiwidgets-orgunittree-node-label"]', { + log: false, + }) + .first() + .then($label => { + if (singleSelection) { + cy.wrap($label) + .find('.checked', { log: false }) + .should('exist') + } else { + cy.wrap($label) + .find('input', { log: false }) + .should('have.prop', 'checked', true) + } + }) + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'shouldBeASelectedOrgUnitNode', + { prevSubject: true }, + shouldBeASelectedOrgUnitNode +) diff --git a/cypress/support/org_unit_tree/shouldBeAnOpenNode.js b/cypress/support/org_unit_tree/shouldBeAnOpenNode.js new file mode 100644 index 0000000000..499596e5dc --- /dev/null +++ b/cypress/support/org_unit_tree/shouldBeAnOpenNode.js @@ -0,0 +1,16 @@ +const shouldBeAnOpenNode = subject => { + cy.wrap(subject, { log: false }) + .shouldBeAnOrgUnitNode() + .find('[data-test="dhis2-uiwidgets-orgunittree-node-toggle"]', { + log: false, + }) + .should('have.class', 'open') + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'shouldBeAnOpenNode', + { prevSubject: true }, + shouldBeAnOpenNode +) diff --git a/cypress/support/org_unit_tree/shouldBeAnOrgUnitNode.js b/cypress/support/org_unit_tree/shouldBeAnOrgUnitNode.js new file mode 100644 index 0000000000..898560663b --- /dev/null +++ b/cypress/support/org_unit_tree/shouldBeAnOrgUnitNode.js @@ -0,0 +1,11 @@ +const shouldBeAnOrgUnitNode = subject => { + expect(subject).to.match('[data-test="dhis2-uiwidgets-orgunittree-node"]') + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'shouldBeAnOrgUnitNode', + { prevSubject: true }, + shouldBeAnOrgUnitNode +) diff --git a/cypress/support/org_unit_tree/shouldBeDoneLoading.js b/cypress/support/org_unit_tree/shouldBeDoneLoading.js new file mode 100644 index 0000000000..46b609ba2c --- /dev/null +++ b/cypress/support/org_unit_tree/shouldBeDoneLoading.js @@ -0,0 +1,13 @@ +const shouldBeDoneLoading = subject => { + cy.wrap(subject, { log: false }).find( + '> [data-test="dhis2-uiwidgets-orgunittree-node-toggle"]' + ) + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'shouldBeDoneLoading', + { prevSubject: true }, + shouldBeDoneLoading +) diff --git a/cypress/support/org_unit_tree/shouldHaveChildNodes.js b/cypress/support/org_unit_tree/shouldHaveChildNodes.js new file mode 100644 index 0000000000..7bd060a85a --- /dev/null +++ b/cypress/support/org_unit_tree/shouldHaveChildNodes.js @@ -0,0 +1,14 @@ +const shouldHaveChildNodes = subject => { + cy.wrap(subject, { log: false }) + .shouldBeAnOrgUnitNode() + .find('[data-test="dhis2-uiwidgets-orgunittree-node"]', { log: false }) + .should('exist') + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'shouldHaveChildNodes', + { prevSubject: true }, + shouldHaveChildNodes +) diff --git a/cypress/support/org_unit_tree/shouldNotBeASelectedOrgUnitNode.js b/cypress/support/org_unit_tree/shouldNotBeASelectedOrgUnitNode.js new file mode 100644 index 0000000000..a7cc800c97 --- /dev/null +++ b/cypress/support/org_unit_tree/shouldNotBeASelectedOrgUnitNode.js @@ -0,0 +1,27 @@ +const shouldNotBeASelectedOrgUnitNode = (subject, singleSelection) => { + cy.wrap(subject, { log: false }) + .shouldBeAnOrgUnitNode() + .find('[data-test="dhis2-uiwidgets-orgunittree-node-label"]', { + log: false, + }) + .first() + .then($label => { + if (singleSelection) { + cy.wrap($label) + .find('.checked', { log: false }) + .should('not.exist') + } else { + cy.wrap($label) + .find('input', { log: false }) + .should('have.prop', 'checked', false) + } + }) + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'shouldNotBeASelectedOrgUnitNode', + { prevSubject: true }, + shouldNotBeASelectedOrgUnitNode +) diff --git a/cypress/support/org_unit_tree/shouldNotHaveChildNodes.js b/cypress/support/org_unit_tree/shouldNotHaveChildNodes.js new file mode 100644 index 0000000000..133296bb5c --- /dev/null +++ b/cypress/support/org_unit_tree/shouldNotHaveChildNodes.js @@ -0,0 +1,14 @@ +const shouldNotHaveChildNodes = subject => { + cy.wrap(subject, { log: false }) + .shouldBeAnOrgUnitNode() + .find('[data-test="dhis2-uiwidgets-orgunittree-node"]', { log: false }) + .should('not.exist') + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'shouldNotHaveChildNodes', + { prevSubject: true }, + shouldNotHaveChildNodes +) diff --git a/cypress/support/org_unit_tree/toggleOrgUnitNode.js b/cypress/support/org_unit_tree/toggleOrgUnitNode.js new file mode 100644 index 0000000000..a1b919cab8 --- /dev/null +++ b/cypress/support/org_unit_tree/toggleOrgUnitNode.js @@ -0,0 +1,28 @@ +const toggleOrgUnitNode = (subject, toggle = true) => { + cy.wrap(subject, { log: false }) + .as('toggleOrgUnitNode__node') + .shouldBeAnOrgUnitNode() + + if (toggle) { + cy.get('@toggleOrgUnitNode__node', { log: false }).shouldBeAClosedNode() + } else { + cy.get('@toggleOrgUnitNode__node', { log: false }).shouldBeAnOpenNode( + true + ) + } + + cy.get('@toggleOrgUnitNode__node', { log: false }) + .find('[data-test="dhis2-uiwidgets-orgunittree-node-toggle"]', { + log: false, + }) + .first() + .click() + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'toggleOrgUnitNode', + { prevSubject: true }, + toggleOrgUnitNode +) diff --git a/cypress/support/org_unit_tree/toggleOrgUnitNodeSelection.js b/cypress/support/org_unit_tree/toggleOrgUnitNodeSelection.js new file mode 100644 index 0000000000..f30c5ada44 --- /dev/null +++ b/cypress/support/org_unit_tree/toggleOrgUnitNodeSelection.js @@ -0,0 +1,34 @@ +const toggleOrgUnitNodeSelection = ( + subject, + select = true, + singleSelection = false +) => { + cy.wrap(subject, { log: false }) + .as('toggleOrgUnitNode__node') + .shouldBeAnOrgUnitNode() + + if (select) { + cy.get('@toggleOrgUnitNode__node', { + log: false, + }).shouldNotBeASelectedOrgUnitNode(singleSelection) + } else { + cy.get('@toggleOrgUnitNode__node', { + log: false, + }).shouldBeASelectedOrgUnitNode(singleSelection) + } + + cy.get('@toggleOrgUnitNode__node', { log: false }) + .find('[data-test="dhis2-uiwidgets-orgunittree-node-label"]', { + log: false, + }) + .first() + .click() + + return cy.wrap(subject, { log: false }) +} + +Cypress.Commands.add( + 'toggleOrgUnitNodeSelection', + { prevSubject: true }, + toggleOrgUnitNodeSelection +) diff --git a/cypress/support/screenshots.js b/cypress/support/screenshots.js new file mode 100644 index 0000000000..ebf190f8c4 --- /dev/null +++ b/cypress/support/screenshots.js @@ -0,0 +1,5 @@ +const takeScreenshots = !!Cypress.env('SCREENSHOT') + +Cypress.Screenshot.defaults({ + screenshotOnRunFailure: takeScreenshots, +}) diff --git a/cypress/support/selectSelectNthOption.js b/cypress/support/selectSelectNthOption.js new file mode 100644 index 0000000000..029a0983c7 --- /dev/null +++ b/cypress/support/selectSelectNthOption.js @@ -0,0 +1,16 @@ +function selectSelectNthOption(subject, index, closeMenu = false) { + cy.wrap(subject) + .find('label + div > .root > .root-input') + .click() + cy.get(`.backdrop > div > div > div > div:nth-child(${index})`).click() + + if (closeMenu) { + cy.get('.backdrop').click('topRight') // close menu + } +} + +Cypress.Commands.add( + 'selectSelectNthOption', + { prevSubject: true }, + selectSelectNthOption +) diff --git a/cypress/support/uploadFile/hexStringToByte.js b/cypress/support/uploadFile/hexStringToByte.js new file mode 100644 index 0000000000..be9bb291c1 --- /dev/null +++ b/cypress/support/uploadFile/hexStringToByte.js @@ -0,0 +1,16 @@ +/** + * @param {string} str + * returns {Uint8Array} + */ +export function hexStringToByte(str) { + if (!str) { + return new Uint8Array() + } + + var a = [] + for (var i = 0, len = str.length; i < len; i += 2) { + a.push(parseInt(str.substr(i, 2), 16)) + } + + return new Uint8Array(a) +} diff --git a/cypress/support/uploadMultipleFiles.js b/cypress/support/uploadMultipleFiles.js new file mode 100644 index 0000000000..3ab4a72a26 --- /dev/null +++ b/cypress/support/uploadMultipleFiles.js @@ -0,0 +1,44 @@ +import { hexStringToByte } from './uploadFile/hexStringToByte' + +/** + * @param {Array.<{ fileType: string, fixture: string }>} files + * @param {string} selector + */ +function uploadMultipleFiles(subject, files) { + const dataTransfer = new DataTransfer() + + cy.wrap(subject).as('element') + cy.get('@element').then($el => { + const files = [...$el[0].files] + files.forEach(file => dataTransfer.items.add(file)) + }) + + files + .reduce((cyp, { fileType, fixture }) => { + cy.fixture(fixture, 'hex').then(fileHex => { + const fileBytes = hexStringToByte(fileHex) + const fileName = fixture.replace(/.+\//g, '') + const testFile = new File([fileBytes], fileName, { + type: fileType, + }) + + dataTransfer.items.add(testFile) + }) + + return cyp + }, cy) + .then(() => { + cy.get('@element').then($el => ($el[0].files = dataTransfer.files)) + // for some reasons trigger causes the `.files` prop to be empty. + // Only trigger when wanting to inspect the event + cy.get('@element').trigger('change', { force: true }) + }) + + return cy +} + +Cypress.Commands.add( + 'uploadMultipleFiles', + { prevSubject: true }, + uploadMultipleFiles +) diff --git a/cypress/support/uploadSingleFile.js b/cypress/support/uploadSingleFile.js new file mode 100644 index 0000000000..2951fc7d01 --- /dev/null +++ b/cypress/support/uploadSingleFile.js @@ -0,0 +1,33 @@ +import { hexStringToByte } from './uploadFile/hexStringToByte' + +/** + * @param {string} fileType + * @param {string} fixture + * @param {string} selector + */ +function uploadSingleFile(subject, fileType, fixture) { + cy.fixture(fixture, 'hex') + .then(fileHex => { + const fileBytes = hexStringToByte(fileHex) + const fileName = fixture.replace(/.+\//g, '') + const testFile = new File([fileBytes], fileName, { + type: fileType, + }) + const dataTransfer = new DataTransfer() + dataTransfer.items.add(testFile) + + return dataTransfer.files + }) + .then(files => { + cy.wrap(subject).as('element') + cy.get('@element').then($el => ($el[0].files = files)) + cy.get('@element').trigger('change', { force: true }) + return cy + }) +} + +Cypress.Commands.add( + 'uploadSingleFile', + { prevSubject: true }, + uploadSingleFile +) diff --git a/cypress/support/verifyFormValue.js b/cypress/support/verifyFormValue.js new file mode 100644 index 0000000000..10d15f1731 --- /dev/null +++ b/cypress/support/verifyFormValue.js @@ -0,0 +1,5 @@ +Cypress.Commands.add('verifyFormValue', (fieldName, expectedValue) => { + cy.getFormValue(fieldName).then(actualValue => { + expect(actualValue).to.equal(expectedValue) + }) +}) diff --git a/cypress/support/visitStory.js b/cypress/support/visitStory.js new file mode 100644 index 0000000000..d8f9dd0085 --- /dev/null +++ b/cypress/support/visitStory.js @@ -0,0 +1,26 @@ +import { toId } from '@storybook/csf' + +Cypress.Commands.add('visitStory', (namespace, storyName, options = {}) => { + const id = toId(namespace, storyName) + + /** + * See + * + * * https://github.com/cypress-io/cypress/issues/95 + * * https://github.com/cypress-io/cypress-example-recipes/tree/master/examples/stubbi + * * https://github.com/cypress-io/cypress-example-recipes/blob/master/examples/stubbi + * + * for an explanation why this is currently necessay... + */ + return cy.readFile('cypress/assets/unfetch.umd.js').then(content => { + return cy.visit(`iframe.html?id=${id}`, { + ...options, + onBeforeLoad: win => { + delete win.fetch + win.eval(content) + win.fetch = win.unfetch + options.onBeforeLoad && options.onBeforeLoad(win) + }, + }) + }) +}) diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000000..2fa992c0b8 --- /dev/null +++ b/docs/.gitkeep @@ -0,0 +1 @@ +keep diff --git a/docs/_sidebar.md b/docs/_sidebar.md new file mode 100644 index 0000000000..dbe5b89cdd --- /dev/null +++ b/docs/_sidebar.md @@ -0,0 +1,3 @@ +- [**Getting started**](getting-started) +- [**API**](api) +- [**Live demos**](/demo/ ':ignore Live demos') diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000000..0c52ac75c3 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,19 @@ +# Getting Started + +[![npm](https://img.shields.io/npm/v/@dhis2/ui-core.svg)](https://www.npmjs.com/package/@dhis2/ui-core) + +## Installation + +```bash +yarn add @dhis2/ui-core +``` + +## Requirements + +### React >= 16.3 + +This library uses the official React Context API (introduced in 16.3) and React Fragments. + +### Polyfills + +> TODO diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000000..157764e309 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + rootDir: '.', + setupFilesAfterEnv: ['config/jest/enzymeSetup.js'], + roots: ['/packages/core/src', '/packages/widgets/src'], +} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000000..f0dba048c9 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "yarn install --frozen-lockfile && yarn build && yarn build:docs && yarn build:demo" + publish = "dist/" diff --git a/package.json b/package.json new file mode 100644 index 0000000000..f956336ce4 --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "root", + "version": "5.0.0", + "repository": "git@github.com:dhis2/ui-core.git", + "author": "Viktor Varland ", + "license": "BSD-3-Clause", + "private": true, + "workspaces": { + "packages": [ + "packages/*", + "storybook" + ], + "nohoist": [ + "storybook/@dhis2/cli-app-scripts", + "storybook/@dhis2/cli-app-scripts/**" + ] + }, + "scripts": { + "build": "concurrently -n w: \"yarn:build:*\"", + "build:constants": "yarn workspace @dhis2/ui-constants build", + "build:core": "yarn workspace @dhis2/ui-core build", + "build:forms": "yarn workspace @dhis2/ui-forms build", + "build:icons": "yarn workspace @dhis2/ui-icons build", + "build:ui": "yarn workspace @dhis2/ui build", + "build:widgets": "yarn workspace @dhis2/ui-widgets build", + "cy:server": "yarn build && STORYBOOK_TESTING=1 yarn watch:storybook", + "cy:open": "concurrently --kill-others --success --first -n cy:server,cy:open 'yarn cy:server' 'wait-on http-get://localhost:5000 && d2-utils-cypress open'", + "cy:run": "concurrently --kill-others --success --first -n cy:server,cy:run 'yarn cy:server' 'wait-on http-get://localhost:5000 && d2-utils-cypress run'", + "demo": "yarn workspace storybook build -o ../dist/demo --quiet", + "docs": "d2-utils-docsite build ./docs -o ./dist --jsdoc ./packages/**/src --jsdoc-output-file api.md", + "format": "yarn format:js && yarn format:text", + "format:staged": "yarn format:js --staged && yarn format:text --staged", + "format:js": "d2-style js apply", + "format:text": "d2-style text apply", + "lint": "concurrently -n w: \"yarn:lint:*\"", + "lint:js": "d2-style js check", + "lint:text": "d2-style text check", + "start": "concurrently -n w: \"yarn:watch:*\" --kill-others", + "test": "d2-app-scripts test", + "watch:constants": "yarn build:constants --watch --dev", + "watch:core": "yarn build:core --watch --dev", + "watch:forms": "yarn build:forms --watch --dev", + "watch:icons": "yarn build:icons --watch --dev", + "watch:storybook": "yarn workspace storybook start --quiet --ci", + "watch:widgets": "yarn build:widgets --watch --dev" + }, + "devDependencies": { + "@dhis2/cli-app-scripts": "^3.2.5", + "@dhis2/cli-style": "^6.0.0", + "@dhis2/cli-utils-cypress": "^1.0.2", + "@dhis2/cli-utils-docsite": "^1.3.0", + "@storybook/addons": "^5.3.9", + "@storybook/components": "^5.3.14", + "@storybook/csf": "^0.0.1", + "@storybook/preset-create-react-app": "^1.5.2", + "@storybook/react": "^5.3.14", + "@wertarbyte/react-props-md-table": "^1.1.1", + "concurrently": "^5.1.0", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.2", + "react": "16.8", + "react-dev-utils": "^10.2.0", + "react-docgen": "^5.3.0", + "react-dom": "16.8", + "styled-jsx": "^3.2.4", + "typeface-roboto": "^0.0.75", + "wait-on": "^4.0.1" + } +} diff --git a/packages/constants/d2.config.js b/packages/constants/d2.config.js new file mode 100644 index 0000000000..a9c5a5e3ef --- /dev/null +++ b/packages/constants/d2.config.js @@ -0,0 +1,11 @@ +module.exports = { + type: 'lib', + entryPoint: { + lib: 'src/index.js', + }, + docsite: { + jsdoc2md: { + 'module-index-format': 'none', + }, + }, +} diff --git a/packages/constants/package.json b/packages/constants/package.json new file mode 100644 index 0000000000..bd30d9fd57 --- /dev/null +++ b/packages/constants/package.json @@ -0,0 +1,27 @@ +{ + "name": "@dhis2/ui-constants", + "version": "5.0.0", + "description": "Constants used in the UI libs", + "main": "build/cjs/lib.js", + "module": "build/es/lib.js", + "author": "Viktor Varland ", + "license": "BSD-3-Clause", + "private": false, + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/dhis2/ui-core.git" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "d2-app-scripts build" + }, + "dependencies": { + "@dhis2/prop-types": "^1.5.0" + }, + "files": [ + "build" + ] +} diff --git a/packages/constants/src/colors.js b/packages/constants/src/colors.js new file mode 100644 index 0000000000..eeb60e815c --- /dev/null +++ b/packages/constants/src/colors.js @@ -0,0 +1,79 @@ +/** + * @module constants/colors + * @desc DHIS2 color values + */ +export const colors = { + /*blue*/ + blue900: '#093371', + blue800: '#0d47a1', + blue700: '#1565c0', + blue600: '#147cd7', + blue500: '#2196f3', + blue400: '#42a5f5', + blue300: '#90caf9', + blue200: '#c5e3fc', + blue100: '#e3f2fd', + blue050: '#f5fbff', + + /*teal*/ + teal900: '#00332b', + teal800: '#004d40', + teal700: '#00695c', + teal600: '#00796b', + teal500: '#00897b', + teal400: '#009688', + teal300: '#4db6ac', + teal200: '#b2dfdb', + teal100: '#e0f2f1', + teal050: '#f1f9f9', + + /*red*/ + red900: '#330202', + red800: '#891515', + red700: '#b71c1c', + red600: '#c62828', + red500: '#d32f2f', + red400: '#f44336', + red300: '#e57373', + red200: '#ffcdd2', + red100: '#ffe5e8', + red050: '#fff5f6', + + /*yellow*/ + yellow900: '#6f3205', + yellow800: '#bb460d', + yellow700: '#e56408', + yellow600: '#ff8302', + yellow500: '#ff9302', + yellow400: '#ffa902', + yellow300: '#ffc324', + yellow200: '#ffe082', + yellow100: '#ffecb3', + yellow050: '#fff8e1', + + /*green*/ + green900: '#103713', + green800: '#1b5e20', + green700: '#2e7d32', + green600: '#388e3c', + green500: '#43a047', + green400: '#4caf50', + green300: '#a5d6a7', + green200: '#c8e6c9', + green100: '#e8f5e9', + green050: '#f4fbf4', + + /*grey*/ + grey900: '#212934', + grey800: '#404b5a', + grey700: '#4a5768', + grey600: '#6e7a8a', + grey500: '#a0adba', + grey400: '#d5dde5', + grey300: '#e8edf2', + grey200: '#f3f5f7', + grey100: '#f8f9fa', + grey050: '#fbfcfd', + + white: '#ffffff', +} diff --git a/packages/constants/src/elevations.js b/packages/constants/src/elevations.js new file mode 100644 index 0000000000..dc985ea28e --- /dev/null +++ b/packages/constants/src/elevations.js @@ -0,0 +1,10 @@ +/** + * @module constants/elevations + * @desc DHIS2 elevation definitions + */ +export const elevations = { + e100: '0 0 1px 0 rgba(64,75,90,0.20), 0 2px 1px 0 rgba(64,75,90,0.28)', + e200: '0 0 1px 0 rgba(64,75,90,0.29), 0 3px 8px -2px rgba(64,75,90,0.30)', + e300: '0 0 1px 0 rgba(64,75,90,0.30), 0 8px 18px -4px rgba(64,75,90,0.28)', + e400: '0 0 1px 0 rgba(64,75,90,0.30), 0 14px 28px -6px rgba(64,75,90,0.30)', +} diff --git a/packages/constants/src/index.js b/packages/constants/src/index.js new file mode 100644 index 0000000000..2227e75110 --- /dev/null +++ b/packages/constants/src/index.js @@ -0,0 +1,7 @@ +export * from './colors.js' +export * from './theme.js' +export * from './layers.js' +export * from './spacers.js' +export * from './elevations.js' + +export * as sharedPropTypes from './shared-prop-types.js' diff --git a/packages/constants/src/layers.js b/packages/constants/src/layers.js new file mode 100644 index 0000000000..a016b36cf7 --- /dev/null +++ b/packages/constants/src/layers.js @@ -0,0 +1,9 @@ +/** + * @module constants/layers + * @desc DHIS2 application layers + */ +export const layers = { + applicationTop: 2000, + blocking: 3000, + alert: 9999, +} diff --git a/packages/constants/src/shared-prop-types.js b/packages/constants/src/shared-prop-types.js new file mode 100644 index 0000000000..f8a948214c --- /dev/null +++ b/packages/constants/src/shared-prop-types.js @@ -0,0 +1,93 @@ +import propTypes from '@dhis2/prop-types' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module constants/shared-prop-types + * @desc Shared propType definitions for UI components + */ + +/** + * Status propType + * @return {PropType} Mutually exclusive status: valid/warning/error + */ +export const statusPropType = propTypes.mutuallyExclusive( + ['valid', 'warning', 'error'], + propTypes.bool +) + +/** + * Button variant propType + * @return {PropType} Mutually exclusive variants: + * primary/secondary/destructive + */ +export const buttonVariantPropType = propTypes.mutuallyExclusive( + ['primary', 'secondary', 'destructive'], + propTypes.bool +) + +/** + * Size variant propType + * @return {PropType} Mutually exclusive variants: + * small/large + */ +export const sizePropType = propTypes.mutuallyExclusive( + ['small', 'large'], + propTypes.bool +) + +/** + * SingleSelect selected item propType + * @return {PropType} Object with props `label` and `value`. + */ +export const singleSelectedPropType = propTypes.shape({ + label: propTypes.string, + value: propTypes.string, +}) + +/** + * SingleSelect selected item propType + * @return {Array} Array of Objects with props `label` and `value`. + */ +export const multiSelectedPropType = propTypes.arrayOf(singleSelectedPropType) + +/** + * Inside alignment props + * @return {PropType} PropType that validates the inside alignment. + */ +export const insideAlignmentPropType = propTypes.oneOf([ + 'top', + 'middle', + 'bottom', +]) + +/** + * Placement properties against reference element + * @return {PropType} PropType that validates placements. + */ +export const referencePlacementPropType = propTypes.oneOf([ + 'auto', + 'auto-start', + 'auto-end', + 'top', + 'top-start', + 'top-end', + 'bottom', // will be used as default + 'bottom-start', + 'bottom-end', + 'right', + 'right-start', + 'right-end', + 'left', + 'left-start', + 'left-end', +]) + +/** + * Either a functional element or an instance of an Element + * @return {PropType} Validate that prop is either a function or an + * instance of an Element. + */ +export const elementRefPropType = propTypes.oneOfType([ + propTypes.func, + propTypes.shape({ current: propTypes.instanceOf(Element) }), +]) diff --git a/packages/constants/src/spacers.js b/packages/constants/src/spacers.js new file mode 100644 index 0000000000..57b2ee7d9b --- /dev/null +++ b/packages/constants/src/spacers.js @@ -0,0 +1,42 @@ +/** + * @module constants/spacers + * @desc DHIS2 spacer constants + */ + +/** Number values of the spacer definitions */ +export const spacersNum = { + dp4: 4, + dp8: 8, + dp12: 12, + dp16: 16, + dp24: 24, + dp32: 32, + dp48: 48, + dp64: 64, + dp96: 96, + dp128: 128, + dp192: 192, + dp256: 256, + dp384: 384, + dp512: 512, + dp640: 640, +} + +/** Pixel values of the spacer definitions */ +export const spacers = { + dp4: `${spacersNum.dp4}px`, + dp8: `${spacersNum.dp8}px`, + dp12: `${spacersNum.dp12}px`, + dp16: `${spacersNum.dp16}px`, + dp24: `${spacersNum.dp24}px`, + dp32: `${spacersNum.dp32}px`, + dp48: `${spacersNum.dp48}px`, + dp64: `${spacersNum.dp64}px`, + dp96: `${spacersNum.dp96}px`, + dp128: `${spacersNum.dp128}px`, + dp192: `${spacersNum.dp192}px`, + dp256: `${spacersNum.dp256}px`, + dp384: `${spacersNum.dp384}px`, + dp512: `${spacersNum.dp512}px`, + dp640: `${spacersNum.dp640}px`, +} diff --git a/packages/constants/src/theme.js b/packages/constants/src/theme.js new file mode 100644 index 0000000000..9f3acce9e4 --- /dev/null +++ b/packages/constants/src/theme.js @@ -0,0 +1,41 @@ +import { colors } from './colors.js' + +/** + * @module constants/theme + * @desc DHIS2 theme colors + */ +export const theme = { + /* theme */ + fonts: 'Roboto, sans-serif', + + /*primary*/ + primary900: colors.blue900, + primary800: colors.blue800, + primary700: colors.blue700, + primary600: colors.blue600, + primary500: colors.blue500, + primary400: colors.blue400, + primary300: colors.blue300, + primary200: colors.blue200, + primary100: colors.blue100, + primary050: colors.blue050, + + /*secondary*/ + secondary900: colors.teal900, + secondary800: colors.teal800, + secondary700: colors.teal700, + secondary600: colors.teal600, + secondary500: colors.teal500, + secondary400: colors.teal400, + secondary300: colors.teal300, + secondary200: colors.teal200, + secondary100: colors.teal100, + secondary050: colors.teal050, + + /*status*/ + default: colors.grey700, + error: colors.red500, + valid: colors.blue600, + warning: colors.yellow500, + disabled: colors.grey600, +} diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000000..046c78bec5 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,711 @@ +# [4.14.0](https://github.com/dhis2/ui-core/compare/v4.13.0...v4.14.0) (2020-03-09) + + +### Features + +* introduce popover, expose popper, refactor split/dropdown-button ([609b1f2](https://github.com/dhis2/ui-core/commit/609b1f2d4aba582df97115f8d85af1864e986043)) + +# [4.13.0](https://github.com/dhis2/ui-core/compare/v4.12.0...v4.13.0) (2020-03-02) + + +### Features + +* add tag component ([4e068dc](https://github.com/dhis2/ui-core/commit/4e068dcd619cf0a370fd14e7a4a7058122ba96b5)) + +# [4.12.0](https://github.com/dhis2/ui-core/compare/v4.11.1...v4.12.0) (2020-02-21) + + +### Features + +* **divider:** add dense prop ([3012097](https://github.com/dhis2/ui-core/commit/30120975ab9757a7d3d77fde3ae961c371c2dc6f)) +* **menu:** add maxWidth prop and multiline menu items ([bc88be4](https://github.com/dhis2/ui-core/commit/bc88be4dc7e47e3762c107a6801374fdc838c151)) +* **menu-item:** add destructive prop ([2457b0d](https://github.com/dhis2/ui-core/commit/2457b0dbc55a5942df69ae6cbf2541ab65882ecd)) + +## [4.11.1](https://github.com/dhis2/ui-core/compare/v4.11.0...v4.11.1) (2020-02-20) + + +### Bug Fixes + +* remove isrequired, unless omitting the prop will cause an error ([9223a26](https://github.com/dhis2/ui-core/commit/9223a262d1e6ea675dad7103c4658b38d6da8f6c)) + +# [4.11.0](https://github.com/dhis2/ui-core/compare/v4.10.0...v4.11.0) (2020-02-19) + + +### Features + +* **menu item:** add optional target prop ([c9afe3f](https://github.com/dhis2/ui-core/commit/c9afe3fbe854019c861666e9dae8825afc60af4b)) + +# [4.10.0](https://github.com/dhis2/ui-core/compare/v4.9.1...v4.10.0) (2020-02-19) + + +### Features + +* **checkbox:** add indeterminate prop on dom input element ([745a14a](https://github.com/dhis2/ui-core/commit/745a14a3bb8789c3d6fa6dc05c5c7c234e8e0fbc)) + +## [4.9.1](https://github.com/dhis2/ui-core/compare/v4.9.0...v4.9.1) (2020-02-17) + + +### Bug Fixes + +* **menu:** adjust selector for opening submenu ([e03896a](https://github.com/dhis2/ui-core/commit/e03896a9f8dddc82b69dfec181f8a0b3e6165d62)) + +# [4.9.0](https://github.com/dhis2/ui-core/compare/v4.8.0...v4.9.0) (2020-02-11) + + +### Features + +* introduce file-input-field-with-list and onfocus onblur to button ([1552eae](https://github.com/dhis2/ui-core/commit/1552eae937778077ff4b23d9f56c4cf1a7823ecb)) + +# [4.8.0](https://github.com/dhis2/ui-core/compare/v4.7.2...v4.8.0) (2020-02-06) + + +### Features + +* **modal:** add alignment functionality and props ([5dcacf8](https://github.com/dhis2/ui-core/commit/5dcacf8c95e05c228c42147a8c1c9ec7cd7f7a89)) +* **screen cover:** add alignment functionality and props ([6754d56](https://github.com/dhis2/ui-core/commit/6754d56446b42fe1f10688b2b91bd091f67377d4)) + +## [4.7.2](https://github.com/dhis2/ui-core/compare/v4.7.1...v4.7.2) (2020-02-04) + + +### Bug Fixes + +* **node:** use previously replace arrow icon ([1eb0ac1](https://github.com/dhis2/ui-core/commit/1eb0ac1af1a8497fe23f0b936237ce4f0d862872)) + +## [4.7.1](https://github.com/dhis2/ui-core/compare/v4.7.0...v4.7.1) (2020-01-30) + + +### Bug Fixes + +* **button:** adjust arrow icon used in buttons ([02cb546](https://github.com/dhis2/ui-core/commit/02cb54662b6d397decccf483190ca59775522634)) +* **button:** adjust styles and padding ([7b9fb0e](https://github.com/dhis2/ui-core/commit/7b9fb0e9213410cdafa63e288cbb89bfacbe89be)) +* **button:** reduce padding for icon only buttons ([459c610](https://github.com/dhis2/ui-core/commit/459c610ce348f146898b3520d5dc90bbb4eddadb)) +* **dropdown-button:** add dp12 whitespace left of the arrow icon ([78192e4](https://github.com/dhis2/ui-core/commit/78192e4b8bcf93cda1f12d247ce84ae4d763cdea)) +* **splitbutton:** increase padding on right side of split buttons ([87b4e7b](https://github.com/dhis2/ui-core/commit/87b4e7b42350b895b18cd83dfdbba86b58209843)) + +# [4.7.0](https://github.com/dhis2/ui/compare/v4.6.1...v4.7.0) (2020-01-23) + + +### Features + +* **circular loader:** make svg shrink if container size changes ([04fb621](https://github.com/dhis2/ui/commit/04fb6210794e9937c02963cb8a1493b0caabc005)) +* **node:** add content, label & leaves data-test props ([b4807ca](https://github.com/dhis2/ui/commit/b4807ca075583ddcef37d22bd82af4f564694df3)) +* **node:** add custom icon prop ([7a897da](https://github.com/dhis2/ui/commit/7a897da032fcdf9a83dde0e58cdbec019f9aa2fa)) + +## [4.6.1](https://github.com/dhis2/ui/compare/v4.6.0...v4.6.1) (2020-01-08) + + +### Bug Fixes + +* **tabbar tabs:** use correct children prop type ([ce61249](https://github.com/dhis2/ui/commit/ce61249867cfb99c25749c5de689ed64eb87782c)) + +# [4.6.0](https://github.com/dhis2/ui/compare/v4.5.0...v4.6.0) (2020-01-07) + + +### Bug Fixes + +* **select:** don't ignore invalid selection silently ([02740c8](https://github.com/dhis2/ui/commit/02740c8d0de600e37bb423846634ce46b6e0f0d6)) +* **select:** fix incorrect data-test attribute ([a849de6](https://github.com/dhis2/ui/commit/a849de6202bd39b380be384c176696cd3e3655dd)) + + +### Features + +* **select:** add data attributes for options ([196bac1](https://github.com/dhis2/ui/commit/196bac16465e7aaf6251cb5f11a1b1fde3584971)) +* **select:** append loading spinner to children instead of replacing ([f7651b8](https://github.com/dhis2/ui/commit/f7651b82bd1506326425bef7beb8f0dc8c86975a)) + +# [4.5.0](https://github.com/dhis2/ui/compare/v4.4.1...v4.5.0) (2019-12-20) + + +### Features + +* add StackedTable component ([4f358f2](https://github.com/dhis2/ui/commit/4f358f2aab1ef866c4fc57616a401e1098a3ebc7)) + +## [4.4.1](https://github.com/dhis2/ui/compare/v4.4.0...v4.4.1) (2019-12-19) + + +### Bug Fixes + +* align select with input styling ([0465e0e](https://github.com/dhis2/ui/commit/0465e0ec3cc4828551d0f3cf9f1d9cf426acfa87)) + +# [4.4.0](https://github.com/dhis2/ui/compare/v4.3.1...v4.4.0) (2019-12-18) + + +### Features + +* add data-test attributes ([fe77867](https://github.com/dhis2/ui/commit/fe778674044164a4c372e92d5185098405901e24)) + +## [4.3.1](https://github.com/dhis2/ui/compare/v4.3.0...v4.3.1) (2019-12-17) + + +### Bug Fixes + +* relax prop-validation on children to prevent false positives ([410a1ee](https://github.com/dhis2/ui/commit/410a1eef5525b35d9b51c19dcc801273368fbfeb)) + +# [4.3.0](https://github.com/dhis2/ui/compare/v4.2.0...v4.3.0) (2019-12-16) + + +### Features + +* **input:** enable additional types on input ([1db8763](https://github.com/dhis2/ui/commit/1db8763af4e1d58000ae08ab0d5c5814cf40fc6c)) + +# [4.2.0](https://github.com/dhis2/ui/compare/v4.1.3...v4.2.0) (2019-12-16) + + +### Features + +* **modal:** allow modal without actions ([d65a076](https://github.com/dhis2/ui/commit/d65a0769fb42440c61fa5958cae4db7f9a46322b)) + +## [4.1.3](https://github.com/dhis2/ui/compare/v4.1.2...v4.1.3) (2019-12-12) + + +### Bug Fixes + +* **deps:** set dependencies provided by app-platform to peerDeps ([b637df1](https://github.com/dhis2/ui/commit/b637df140d55b39b501bdd5d48058d9b29d9c99e)) + +## [4.1.2](https://github.com/dhis2/ui/compare/v4.1.1...v4.1.2) (2019-12-12) + + +### Bug Fixes + +* **splitbutton:** allow initialfocus on primary button ([511c146](https://github.com/dhis2/ui/commit/511c1461a79a803604bdf059ba6b5e5d393b6a23)) + +## [4.1.1](https://github.com/dhis2/ui/compare/v4.1.0...v4.1.1) (2019-12-04) + + +### Reverts + +* "feat: transfer feature files and fixes" ([765a51b](https://github.com/dhis2/ui/commit/765a51bbed84f0d07745cd52ab08dc9568e16ffc)) + +# [4.1.0](https://github.com/dhis2/ui/compare/v4.0.3...v4.1.0) (2019-12-03) + + +### Features + +* transfer feature files and fixes ([bf26891](https://github.com/dhis2/ui/commit/bf26891140b9cbd6ec6854e68a5cf39bfb157f71)) + +## [4.0.3](https://github.com/dhis2/ui/compare/v4.0.2...v4.0.3) (2019-11-27) + + +### Bug Fixes + +* **select:** update cb signature for disabled options ([e8dd73a](https://github.com/dhis2/ui/commit/e8dd73af29deb5a740c114d093a9f80ccecbdff6)) + +## [4.0.2](https://github.com/dhis2/ui/compare/v4.0.1...v4.0.2) (2019-11-26) + + +### Bug Fixes + +* **select:** fix clear button callback to use new callback signature ([982e6cb](https://github.com/dhis2/ui/commit/982e6cb7ec42ddbdfcc008aa1a714d98b447f57d)) + +## [4.0.1](https://github.com/dhis2/ui/compare/v4.0.0...v4.0.1) (2019-11-25) + + +### Bug Fixes + +* avoid unconditionally calling optional event handlers ([1c49662](https://github.com/dhis2/ui/commit/1c49662d19288e1b9a5f9da5a40169d594c4a2c2)) + +# [4.0.0](https://github.com/dhis2/ui/compare/v3.12.0...v4.0.0) (2019-11-25) + + +### Bug Fixes + +* **alert-stack:** revert changes so it just uses a fixed z-index ([63b6cff](https://github.com/dhis2/ui/commit/63b6cff4d24fe5b1e42e2c5ce152390387f87323)) +* **checkbox:** align focus/blur with change and remove disabled check ([7527002](https://github.com/dhis2/ui/commit/75270025604d3e346a42c404fdef7ddb2761b1f1)) +* **checkbox,radio:** value should be optional ([#419](https://github.com/dhis2/ui/issues/419)) ([05306a2](https://github.com/dhis2/ui/commit/05306a2639a140261b7e3f6d3ad816e15655b493)) +* **chip remove icon:** fix position cross browser ([40d86a0](https://github.com/dhis2/ui/commit/40d86a08624def0eb2feca4d8a923f2802c48bfe)) +* **css-variables:** improve robustness and developer warnings ([#449](https://github.com/dhis2/ui/issues/449)) ([352a4d1](https://github.com/dhis2/ui/commit/352a4d104e1f0f39f32bb4d7515b97793dba5fbe)) +* **file-field:** consistently use FileField instead of File + fix typo ([d51b119](https://github.com/dhis2/ui/commit/d51b119a21411e65e0682e99d9ae9b181d7b7b58)) +* **file-input:** adjust component names - FormControl to Field ([d31e861](https://github.com/dhis2/ui/commit/d31e86144defa396cdeb4e28512ddca336f0f5d8)) +* **file-input:** correct line height and add className prop ([c6b79d5](https://github.com/dhis2/ui/commit/c6b79d585aaa8ae7137652ddf4b9704cce4dfd5e)) +* **file-input:** fix Edge text overflow behavior ([2f4a1cf](https://github.com/dhis2/ui/commit/2f4a1cf946ed79fcd5539cbb6a46a770f0a537e4)) +* **file-input:** implement PlaceHolder as child of FileList ([4d70143](https://github.com/dhis2/ui/commit/4d701433bbb97325c38ae61073eb783480a3a16b)) +* **file-input:** implement spacing between text and link via ::after el ([f4ae821](https://github.com/dhis2/ui/commit/f4ae821a38a96703bab22a430cc7f9f7063b9562)) +* **file-input:** improve children prop-type to allow multiple files ([e7785fd](https://github.com/dhis2/ui/commit/e7785fda58528bb61755f5de796303e0e69b6629)) +* **file-input:** introduce FileList to solve spacing issues ([5faae50](https://github.com/dhis2/ui/commit/5faae50ab48f93bdc1ad514dd4a09d0a1c403caa)) +* **file-input:** tweak CSS to fix incorrect total height ([79d8df0](https://github.com/dhis2/ui/commit/79d8df08ed033ec4303af90f7104305f1f7a90d7)) +* **input:** align handleFocus/handleBlur with handleChange ([848d5fe](https://github.com/dhis2/ui/commit/848d5fe72b0ef75ae774298ff1e4edcc58bed1df)) +* **input:** allow types specified in design-system ([29127f3](https://github.com/dhis2/ui/commit/29127f3938df677494a4e94619add02537571aab)) +* **input:** correct prop-type for type ([066e468](https://github.com/dhis2/ui/commit/066e468951aba5331741f55440a4a45431a73d96)) +* **input:** remove duplicate story and improve "No label" ([#480](https://github.com/dhis2/ui/issues/480)) ([0a40894](https://github.com/dhis2/ui/commit/0a40894a52db088ca2cfbbe6daaed10102127e9f)) +* **input-field:** add required `onChange` prop to all stories ([d0060bc](https://github.com/dhis2/ui/commit/d0060bc11c8d95e8517c4ef896b9a27c2fc72fa4)) +* **input-field:** adjust focus props and label story ([#447](https://github.com/dhis2/ui/issues/447)) ([43df32e](https://github.com/dhis2/ui/commit/43df32efddec4b1bd0e252b24a06c2c2a1020a85)) +* **layer:** children must be a function and required ([ec75085](https://github.com/dhis2/ui/commit/ec75085b2a0738c5c2c2c1040a3b11588fc6ba8b)) +* **layer context:** simplify and correct calculations ([4a14e3f](https://github.com/dhis2/ui/commit/4a14e3fccee8e2c87561a3341766afa7b7b38f1e)) +* **layer-context:** add support for layers upon layers ([677e585](https://github.com/dhis2/ui/commit/677e58526d1986f38cc20a65eb0657da4993a45f)) +* **prop-types:** adjust statusPropType and use custom one for AlertBar ([85d40ba](https://github.com/dhis2/ui/commit/85d40ba252c069590d00318a10b03f503dedab0c)) +* **radio:** align handleFocus/handleBlur with handleChange ([d9b6305](https://github.com/dhis2/ui/commit/d9b63058ccbe0d35801461c6fd88e0bc6dea89bb)) +* **select:** add missing prop types ([fc4e499](https://github.com/dhis2/ui/commit/fc4e499f9321777ab4b1d43e41c4b5efae477a35)) +* **select:** add missing tab index default ([85904e3](https://github.com/dhis2/ui/commit/85904e373f174d3dd0822d9da18db2d28a4975b4)) +* **select:** align onfocus and onblur with other cbs ([a607f31](https://github.com/dhis2/ui/commit/a607f317a30f77543fdccf38d5425ad9f9489eb5)) +* **select:** align select with callback style changes ([456d116](https://github.com/dhis2/ui/commit/456d116f10e10f88d27a1eea1fcc40f1b9c0ed86)) +* **select:** fix overlap on specifying input width for flex children ([54a15aa](https://github.com/dhis2/ui/commit/54a15aafde5bae9698f5c4b42e95c672094a0f7c)) +* **select:** fix positioning issues ([6bd8a2b](https://github.com/dhis2/ui/commit/6bd8a2bb9ccb3b0f31397ac7d196d04f80fbbeb9)) +* **select:** fix select closing on chip removal ([b51d101](https://github.com/dhis2/ui/commit/b51d101765ca1bf9a83e98bf6de4b78057ed2673)) +* **text-area:** only set height when value changes ([9ec7437](https://github.com/dhis2/ui/commit/9ec74370e2a3a1e55d3b135e0bd16e3616ed7b90)) +* **toggle-group:** remove `.isRequired` from `value` prop ([abb790c](https://github.com/dhis2/ui/commit/abb790c7d94d543d6bb7b06c6db35c31959f586f)) +* adjust menu position after option selection ([a5b9385](https://github.com/dhis2/ui/commit/a5b9385eec3057e608c2e6d0a3e5c2076cf0c84f)) +* correct handler usage for filterable menu ([7f53a63](https://github.com/dhis2/ui/commit/7f53a63cc7f63d8b5f6a99ebdf28426c904f11a1)) +* export Label in index.js ([85187b9](https://github.com/dhis2/ui/commit/85187b9bb79a7ff5c1a948049ac68f89200f8fab)) +* introduce layer-context to deal with overlapping elements + story ([6632093](https://github.com/dhis2/ui/commit/66320937bb88f09b57e6bdb33d7cfb3ec161aaa6)) +* use layer-context in alert-stack and drop-menu ([2caece4](https://github.com/dhis2/ui/commit/2caece47101c73050b8e6dc7f042b9c7bf2197b7)) +* **select:** make filtering case insensitive ([6d997a8](https://github.com/dhis2/ui/commit/6d997a84298e5bbd772a03e594730e3beb786149)) +* **split-button:** remove unused styles ([134fd16](https://github.com/dhis2/ui/commit/134fd168b8afc14989401519948b7b76526c355b)) +* **switch:** align handleFocus/handleBlur with handleChange ([92550c8](https://github.com/dhis2/ui/commit/92550c8c62e7446d0d1a03b51fbda3ebe1a5f91b)) +* **tab-bar:** enforce that children are instances of the Tab component ([d583188](https://github.com/dhis2/ui/commit/d5831886e61d031cb2deb59ebeacc3da1f779496)) +* **text-area:** align handleFocus/handleBlur with handleChange ([2f3ad48](https://github.com/dhis2/ui/commit/2f3ad48f0696f1ab8c8f3e9f0a8456619b067468)) +* make name prop optional instead of required ([ab8f6a5](https://github.com/dhis2/ui/commit/ab8f6a5f52b2908ff5399159d1ee67da3d0a9cab)) +* **switch:** change input type to checkbox because switch is not valid ([245f8bf](https://github.com/dhis2/ui/commit/245f8bf5a3244bb5bb9efba248e541b75bb86359)) +* **toggles:** remove position relative from toggles ([a3f5e2e](https://github.com/dhis2/ui/commit/a3f5e2e84ed6938fe1cf2bacb4d31e2188952f1b)) + + +### chore + +* upgrade react to support hooks ([35672e0](https://github.com/dhis2/ui/commit/35672e046aacc960f128c5d9de5d431c25141700)) +* **help:** remove `indent` props and examples from stories ([#471](https://github.com/dhis2/ui/issues/471)) ([12efecb](https://github.com/dhis2/ui/commit/12efecb8a669075ea241fc39ce19523ed7a6f532)) + + +### Code Refactoring + +* **checkbox:** align 'on' function handlers ([53c700d](https://github.com/dhis2/ui/commit/53c700dba4e7eab2d268384c23b7fdb3e72b1445)) +* **fileinput:** align 'on' function handlers ([8829eec](https://github.com/dhis2/ui/commit/8829eecabfa716a8f2306f945c6af7cb5d807e49)) +* **formcontrol:** rename to field ([#402](https://github.com/dhis2/ui/issues/402)) ([b9205e1](https://github.com/dhis2/ui/commit/b9205e1b5a4d1102588448f4717b22222adc791f)) +* **input:** align 'on' function handlers ([2ab28b7](https://github.com/dhis2/ui/commit/2ab28b76caa254af2b1fe6941fbaf7cd18bfb81c)) +* **input,textarea:** remove option to pass in number for width ([77105f2](https://github.com/dhis2/ui/commit/77105f21a937dbdce91963e5918a9cdd079d12e9)) +* **modal:** align modal with component structure ([b276a31](https://github.com/dhis2/ui/commit/b276a31cc7a8cbbd103f11a3db2e225998c7d72a)) +* **modal:** remove open prop ([9da92f1](https://github.com/dhis2/ui/commit/9da92f169de31411756b8497e36303ee8f3ab6c9)) +* **modal:** remove unnecessary aside element wrapper ([5e39456](https://github.com/dhis2/ui/commit/5e3945692013aa09de09676556ea7cf60c26e7f4)) +* **radio:** align 'on' function handlers ([611a394](https://github.com/dhis2/ui/commit/611a3941bc0b1c5ce2f037c5111bf2569ce76505)) +* **select:** update component to new design ([e810a78](https://github.com/dhis2/ui/commit/e810a7825e5507fa8ddb5d779c19e39398e68cf3)) +* **splitbutton:** separate component from buttonstrip ([#396](https://github.com/dhis2/ui/issues/396)) ([f24cd11](https://github.com/dhis2/ui/commit/f24cd119c5220c13e190e502ccb0e7193ee3fe40)) +* **switch:** align 'on' function handlers ([5011d6d](https://github.com/dhis2/ui/commit/5011d6d0e4364fc5c6182682e1cd66bbfe570a6c)) +* **tabbar:** align component structure ([e3313f3](https://github.com/dhis2/ui/commit/e3313f3e7af32c380904b631b10060fd5ce7e816)) +* **textarea:** align 'on' function handlers ([44ef1bf](https://github.com/dhis2/ui/commit/44ef1bfa9478fae5fe5e684a9e8aa9cb3efa96cf)) +* implement the Radio component consistently with other inputs ([8fed62f](https://github.com/dhis2/ui/commit/8fed62fd34a5f11ae14b2976f4d8e6b7398e53bb)) +* kill the filled option for selectfield and inputfield ([#427](https://github.com/dhis2/ui/issues/427)) ([e0eabdb](https://github.com/dhis2/ui/commit/e0eabdb17ed822a61f91403b3c30c0e3e298ab32)) +* toggle input components ([b51b71c](https://github.com/dhis2/ui/commit/b51b71cb3de7298c4dfb05401ab295fd8b547b17)) +* **select,input:** drop -field suffix on components ([#389](https://github.com/dhis2/ui/issues/389)) ([eb104f5](https://github.com/dhis2/ui/commit/eb104f5308d04ddeaeadd3a080bac54027360470)) +* **toggle-group:** split into composable components ([#388](https://github.com/dhis2/ui/issues/388)) ([e4beb91](https://github.com/dhis2/ui/commit/e4beb91a4f90cfb60f037d505f9b6de6ad98296d)) + + +### Documentation + +* introduce user docs ([#397](https://github.com/dhis2/ui/issues/397)) ([c3bc557](https://github.com/dhis2/ui/commit/c3bc5579d4a606d50cb53bd21f9130d2ca3cbbd1)) + + +### Features + +* **field:** add className prop ([7565d59](https://github.com/dhis2/ui/commit/7565d5920c6a9b6f6941392c5a70ac09cacee9ed)) +* **file-field:** introduce FileField component ([0423f62](https://github.com/dhis2/ui/commit/0423f6275002560900c66cfd2b32006900076000)) +* **file-input:** add disabled prop and button sizes ([066538f](https://github.com/dhis2/ui/commit/066538ff17ce0a882abafccc33928554937fae25)) +* **input atom:** add ellipsis to overflowing text ([56ce804](https://github.com/dhis2/ui/commit/56ce804b9afbc06b24065d8907cbed3cd3f93c2b)) +* **label:** add className prop ([8526560](https://github.com/dhis2/ui/commit/8526560d030fb2f63737f806ba19b8fe8fff071c)) +* **modal:** add className prop ([6c231d6](https://github.com/dhis2/ui/commit/6c231d617b32032594ddd8240d606ec786c44369)) +* **node:** add className prop to node ([1468e35](https://github.com/dhis2/ui/commit/1468e35d5c4bda587dbd7ab9b726ccbe1ebfaeac)) +* **screencover:** add transparent prop ([90a5881](https://github.com/dhis2/ui/commit/90a58812ccd095b9612c8720aec2450712d17b76)) +* **select:** add input max height prop ([75555b3](https://github.com/dhis2/ui/commit/75555b30a824753dcf9c3d8bc17ec6bc5c2d12e8)) +* **select:** add inputWidth property to control width ([9eefe22](https://github.com/dhis2/ui/commit/9eefe226de643f77cec7c8df42644c057f55ce86)) +* **status-icon:** add `info` and `defaultTo` props ([8144311](https://github.com/dhis2/ui/commit/8144311809a318935b3ad641423cc70f083e803f)) +* **tab components:** add className props ([480f538](https://github.com/dhis2/ui/commit/480f5382bcd8c408384c95088229b59442e996b8)) +* **textarea:** introduce TextArea and TextAreaField with docs and stories ([#456](https://github.com/dhis2/ui/issues/456)) ([0e73d7a](https://github.com/dhis2/ui/commit/0e73d7abb32f900e12e1155bfdd3a0af15e06b7e)) +* **theme:** expose spacers, elevations and layers constants ([76f34ba](https://github.com/dhis2/ui/commit/76f34ba361fb4bf72251255095c0c0bd094bc8d6)) + + +### BREAKING CHANGES + +* **modal:** Removed the aside element wrapper +* **modal:** Removing the "open" prop from the Modal. +* **input,textarea:** Input and TextArea now requires the input width +override to be passed in as a string. +* Now requires React >= 16.8. +* **fileinput:** `onChange` is called with two parameters: +First parameter an object with properties for `checked`, `name`, and +`value`. +The second is the React `event` object. +* **textarea:** `onChange` is called with two parameters: +First parameter an object with properties for `checked`, `name`, and +`value`. +The second is the React `event` object. +* **input:** `onChange` is called with two parameters: +First parameter an object with properties for `checked`, `name`, and +`value`. +The second is the React `event` object. +* **checkbox:** `onChange` is called with two parameters: +First parameter an object with properties for `checked`, `name`, and +`value`. +The second is the React `event` object. +* **radio:** `onChange` is called with two parameters: +First parameter an object with properties for `checked`, `name`, and +`value`. +The second is the React `event` object. +* **switch:** `onChange` is called with two parameters: +First parameter an object with properties for `checked`, `name`, and +`value`. +The second is the React `event` object. +* **tabbar:** This replaces the need to nest a TabBar in a ScrollBar, +and instead allows `TabBar.propTypes.scrollable` to toggle if a TabBar +is scrollable. +* **tabbar:** This deprecates external use of ScrollBar. +* **modal:** This deprecated use of Modal.Actions. Instead, use +ModalActions. +* **modal:** This deprecated use of Modal.Content. Instead, use +ModalContent. +* **modal:** This deprecated use of Modal.Title. Instead, use +ModalTitle. +* **tab-bar:** TabBar will now only accept Tab instances +* **select:** Select deprecated in favor of the SingleSelect, refer +to the docs for the API. +* **select:** SelectField deprecated in favor of SingleSelectField, +see the docs for the API. +* **select:** The new Select components do not accept a standard HTML `option` +element, instead they require use of SingleSelectOption, MultiSelectOption, +or a component with a matching API. +* **select:** The Select's `onChange` no longer returns a standard `Event.target`, +instead returns the selected Object with properties: `label` and +`value`, or an array of those for the MultiSelects +* **select:** The Selects all require a selected prop, instead of a value. +Where selected is an object with a label and a value string, or an array of +those for the MultiSelect. +* **select:** Since the Selects do not use a native input element, the name +prop has been dropped from the Select components. +* The `icon` and `required` props have been removed from +the Checkbox and Switch. For Switch, the `label` prop has become +required. +* RadioGroup has a new implementation and drops the +following props: label, inline, options, and helpText. +* The `icon` and `required` props have been removed from the Radio component and the `value` and `label` props have become required. +* **help:** Removes indent prop from Help. Use className to +override if necessary. +* Remove the Filled field style for SelectField and +InputField. +* MenuItem value prop is more strict, requires String +instead of any. +* **formcontrol:** Renames FormControl to Field. +* **splitbutton:** remove the `compact` prop from buttonstrip +* **toggle-group:** Renames ToggleGroup to FieldSet to match intended +usage. +* **select,input:** **InputField** renamed to **Input** + +**SelectField** renamed to **Select** + +* chore: trigger netlify + +# [3.12.0](https://github.com/dhis2/ui/compare/v3.11.2...v3.12.0) (2019-10-07) + + +### Features + +* **radio-group:** add disabled prop ([#479](https://github.com/dhis2/ui/issues/479)) ([e98d348](https://github.com/dhis2/ui/commit/e98d348)) + +## [3.11.2](https://github.com/dhis2/ui/compare/v3.11.1...v3.11.2) (2019-10-03) + + +### Bug Fixes + +* **radiogroup:** make the label optional ([#475](https://github.com/dhis2/ui/issues/475)) ([57d017c](https://github.com/dhis2/ui/commit/57d017c)) + +## [3.11.1](https://github.com/dhis2/ui/compare/v3.11.0...v3.11.1) (2019-09-30) + + +### Bug Fixes + +* don't use dynamic re-exports ([#459](https://github.com/dhis2/ui/issues/459)) ([7f0a5da](https://github.com/dhis2/ui/commit/7f0a5da)), closes [/github.com/dhis2/app-platform/blob/master/cli/config/rollup.config.js#L77](https://github.com//github.com/dhis2/app-platform/blob/master/cli/config/rollup.config.js/issues/L77) [/github.com/rollup/rollup-plugin-commonjs/issues/211#issuecomment-337897124](https://github.com//github.com/rollup/rollup-plugin-commonjs/issues/211/issues/issuecomment-337897124) + +# [3.11.0](https://github.com/dhis2/ui/compare/v3.10.0...v3.11.0) (2019-09-23) + + +### Features + +* add static table component ([#378](https://github.com/dhis2/ui/issues/378)) ([0209aab](https://github.com/dhis2/ui/commit/0209aab)) + +# [3.10.0](https://github.com/dhis2/ui/compare/v3.9.1...v3.10.0) (2019-09-17) + + +### Features + +* **css-variables:** add css variable injection component ([#421](https://github.com/dhis2/ui/issues/421)) ([0bd20ee](https://github.com/dhis2/ui/commit/0bd20ee)) + +## [3.9.1](https://github.com/dhis2/ui/compare/v3.9.0...v3.9.1) (2019-09-05) + + +### Bug Fixes + +* **menu item:** pass value to on click ([#405](https://github.com/dhis2/ui/issues/405)) ([d371475](https://github.com/dhis2/ui/commit/d371475)) + +# [3.9.0](https://github.com/dhis2/ui/compare/v3.8.0...v3.9.0) (2019-08-22) + + +### Features + +* **radio-group:** introduce RadioGroup component ([#381](https://github.com/dhis2/ui/issues/381)) ([6a88a58](https://github.com/dhis2/ui/commit/6a88a58)) + +# [3.8.0](https://github.com/dhis2/ui/compare/v3.7.3...v3.8.0) (2019-08-22) + + +### Features + +* **form-control:** introduce FormControl component ([#382](https://github.com/dhis2/ui/issues/382)) ([0dfab15](https://github.com/dhis2/ui/commit/0dfab15)) + +## [3.7.3](https://github.com/dhis2/ui/compare/v3.7.2...v3.7.3) (2019-08-22) + + +### Bug Fixes + +* **help:** spacing ([#380](https://github.com/dhis2/ui/issues/380)) ([c9fd20a](https://github.com/dhis2/ui/commit/c9fd20a)) + +## [3.7.2](https://github.com/dhis2/ui/compare/v3.7.1...v3.7.2) (2019-08-19) + + +### Bug Fixes + +* use correct disabled style & fix checkbox and radio disabled style ([#379](https://github.com/dhis2/ui/issues/379)) ([8755a04](https://github.com/dhis2/ui/commit/8755a04)), closes [dhis2/design-system#6](https://github.com/dhis2/design-system/issues/6) + +## [3.7.1](https://github.com/dhis2/ui/compare/v3.7.0...v3.7.1) (2019-08-01) + + +### Bug Fixes + +* **deps-dev:** bump @storybook/components from 5.1.9 to 5.1.10 ([#364](https://github.com/dhis2/ui/issues/364)) ([7063ece](https://github.com/dhis2/ui/commit/7063ece)) + +# [3.7.0](https://github.com/dhis2/ui/compare/v3.6.3...v3.7.0) (2019-07-19) + + +### Features + +* wrap contents with a link element when href or onClick is given ([#354](https://github.com/dhis2/ui/issues/354)) ([9e8d3f0](https://github.com/dhis2/ui/commit/9e8d3f0)) + +## [3.6.3](https://github.com/dhis2/ui/compare/v3.6.2...v3.6.3) (2019-07-16) + + +### Bug Fixes + +* **tab:** increase line-height & decrease padding to preserve height ([#344](https://github.com/dhis2/ui/issues/344)) ([e125c8f](https://github.com/dhis2/ui/commit/e125c8f)) + +## [3.6.2](https://github.com/dhis2/ui/compare/v3.6.1...v3.6.2) (2019-07-15) + + +### Bug Fixes + +* **tabs:** apply flex-grow to ScrollBar and TabBar ([#343](https://github.com/dhis2/ui/issues/343)) ([f048632](https://github.com/dhis2/ui/commit/f048632)) + +## [3.6.1](https://github.com/dhis2/ui/compare/v3.6.0...v3.6.1) (2019-07-15) + + +### Bug Fixes + +* update d2-style and fix but in radio button css ([#338](https://github.com/dhis2/ui/issues/338)) ([29cf886](https://github.com/dhis2/ui/commit/29cf886)) + +# [3.6.0](https://github.com/dhis2/ui/compare/v3.5.0...v3.6.0) (2019-07-10) + + +### Features + +* add tabs component ([#312](https://github.com/dhis2/ui/issues/312)) ([9420365](https://github.com/dhis2/ui/commit/9420365)) + +# [3.5.0](https://github.com/dhis2/ui/compare/v3.4.0...v3.5.0) (2019-07-03) + + +### Features + +* add focus prop to button ([#315](https://github.com/dhis2/ui/issues/315)) ([56697b6](https://github.com/dhis2/ui/commit/56697b6)) + +# [3.4.0](https://github.com/dhis2/ui/compare/v3.3.0...v3.4.0) (2019-06-21) + + +### Features + +* extract and expose MenuList from Menu component ([#297](https://github.com/dhis2/ui/issues/297)) ([4d576af](https://github.com/dhis2/ui/commit/4d576af)) + +# [3.3.0](https://github.com/dhis2/ui/compare/v3.2.1...v3.3.0) (2019-06-17) + + +### Features + +* **checkbox & radio:** add custom icon prop ([#295](https://github.com/dhis2/ui/issues/295)) ([2da6bc5](https://github.com/dhis2/ui/commit/2da6bc5)) + +## [3.2.1](https://github.com/dhis2/ui/compare/v3.2.0...v3.2.1) (2019-06-15) + + +### Bug Fixes + +* remove spacing left to the checkbox icon in FF ([#283](https://github.com/dhis2/ui/issues/283)) ([33165a5](https://github.com/dhis2/ui/commit/33165a5)) + +# [3.2.0](https://github.com/dhis2/ui/compare/v3.1.1...v3.2.0) (2019-06-14) + + +### Features + +* **alertbar:** add the alert bar and alert stack ([#277](https://github.com/dhis2/ui/issues/277)) ([a645093](https://github.com/dhis2/ui/commit/a645093)) + +## [3.1.1](https://github.com/dhis2/ui/compare/v3.1.0...v3.1.1) (2019-06-13) + + +### Bug Fixes + +* **cssreset:** import from the theme file explicitly ([#271](https://github.com/dhis2/ui/issues/271)) ([74d35ad](https://github.com/dhis2/ui/commit/74d35ad)) + +# [3.1.0](https://github.com/dhis2/ui/compare/v3.0.0...v3.1.0) (2019-06-06) + + +### Features + +* add spacing scale (and fix a few bugs) ([#249](https://github.com/dhis2/ui/issues/249)) ([46a3574](https://github.com/dhis2/ui/commit/46a3574)) + +# [3.0.0](https://github.com/dhis2/ui/compare/v2.5.2...v3.0.0) (2019-06-06) + + +### chore + +* **release:** v3.0.0 ([#246](https://github.com/dhis2/ui/issues/246)) ([6df182b](https://github.com/dhis2/ui/commit/6df182b)), closes [#244](https://github.com/dhis2/ui/issues/244) [#245](https://github.com/dhis2/ui/issues/245) [#247](https://github.com/dhis2/ui/issues/247) + + +### BREAKING CHANGES + +* **release:** The CircularProgress and LinearProgress have been renamed to CircularLoader and LinearLoader. +The LinearLoader does not have an indeterminate option any more. +Removes DOM attributes from component API: autofocus, autocomplete, readonly. + +## [2.5.2](https://github.com/dhis2/ui/compare/v2.5.1...v2.5.2) (2019-06-04) + + +### Bug Fixes + +* make form inputs focus-able ([#230](https://github.com/dhis2/ui/issues/230)) ([2fbd0ea](https://github.com/dhis2/ui/commit/2fbd0ea)) + +## [2.5.1](https://github.com/dhis2/ui/compare/v2.5.0...v2.5.1) (2019-05-31) + + +### Bug Fixes + +* **node:** do not count false when calculating the amount of children ([#242](https://github.com/dhis2/ui/issues/242)) ([a00dc19](https://github.com/dhis2/ui/commit/a00dc19)) + +# [2.5.0](https://github.com/dhis2/ui/compare/v2.4.4...v2.5.0) (2019-05-31) + + +### Features + +* add node component ([#215](https://github.com/dhis2/ui/issues/215)) ([2bbd4b4](https://github.com/dhis2/ui/commit/2bbd4b4)) + +## [2.4.4](https://github.com/dhis2/ui/compare/v2.4.3...v2.4.4) (2019-05-29) + + +### Bug Fixes + +* **menu item:** differentiate between hover and active style ([#240](https://github.com/dhis2/ui/issues/240)) ([37c51cb](https://github.com/dhis2/ui/commit/37c51cb)) + +## [2.4.3](https://github.com/dhis2/ui/compare/v2.4.2...v2.4.3) (2019-05-24) + + +### Bug Fixes + +* styling adjustments ([#235](https://github.com/dhis2/ui/issues/235)) ([b0bb96e](https://github.com/dhis2/ui/commit/b0bb96e)) + +## [2.4.2](https://github.com/dhis2/ui/compare/v2.4.1...v2.4.2) (2019-05-24) + + +### Bug Fixes + +* **linting:** ran `d2 style js install` to generate eslint config ([#234](https://github.com/dhis2/ui/issues/234)) ([f75c8ba](https://github.com/dhis2/ui/commit/f75c8ba)) + +## [2.4.1](https://github.com/dhis2/ui/compare/v2.4.0...v2.4.1) (2019-05-23) + + +### Bug Fixes + +* **buttonstrip:** remove default prop so start class is not always added ([#233](https://github.com/dhis2/ui/issues/233)) ([0526051](https://github.com/dhis2/ui/commit/0526051)) + +# [2.4.0](https://github.com/dhis2/ui/compare/v2.3.0...v2.4.0) (2019-05-23) + + +### Features + +* **buttonstrip:** add button strip component ([#227](https://github.com/dhis2/ui/issues/227)) ([63d774e](https://github.com/dhis2/ui/commit/63d774e)) + +# [2.3.0](https://github.com/dhis2/ui/compare/v2.2.3...v2.3.0) (2019-05-23) + + +### Features + +* **prop-types:** mutually exclusive booleans ([#232](https://github.com/dhis2/ui/issues/232)) ([c2ce2c1](https://github.com/dhis2/ui/commit/c2ce2c1)) + +## [2.2.3](https://github.com/dhis2/ui/compare/v2.2.2...v2.2.3) (2019-05-22) + + +### Bug Fixes + +* issue with gatsby ([#231](https://github.com/dhis2/ui/issues/231)) ([cfb4718](https://github.com/dhis2/ui/commit/cfb4718)) + +## [2.2.2](https://github.com/dhis2/ui/compare/v2.2.1...v2.2.2) (2019-05-22) + + +### Bug Fixes + +* introduce the stacking context ([#229](https://github.com/dhis2/ui/issues/229)) ([54acc10](https://github.com/dhis2/ui/commit/54acc10)) + +## [2.2.1](https://github.com/dhis2/ui/compare/v2.2.0...v2.2.1) (2019-05-21) + + +### Bug Fixes + +* add font-family to everything ([#226](https://github.com/dhis2/ui/issues/226)) ([d177230](https://github.com/dhis2/ui/commit/d177230)) + +# [2.2.0](https://github.com/dhis2/ui/compare/v2.1.2...v2.2.0) (2019-05-16) + + +### Features + +* use a portal to overlay the loader ([#223](https://github.com/dhis2/ui/issues/223)) ([61b5581](https://github.com/dhis2/ui/commit/61b5581)) + +## [2.1.2](https://github.com/dhis2/ui/compare/v2.1.1...v2.1.2) (2019-05-15) + + +### Bug Fixes + +* **input field and select field:** crop long labels ([#222](https://github.com/dhis2/ui/issues/222)) ([db6f74e](https://github.com/dhis2/ui/commit/db6f74e)) + +## [2.1.1](https://github.com/dhis2/ui/compare/v2.1.0...v2.1.1) (2019-05-13) + + +### Bug Fixes + +* required star ([#212](https://github.com/dhis2/ui/issues/212)) ([7a2b306](https://github.com/dhis2/ui/commit/7a2b306)) + +# [2.1.0](https://github.com/dhis2/ui/compare/v2.0.2...v2.1.0) (2019-05-09) + + +### Features + +* add modal component ([#205](https://github.com/dhis2/ui/issues/205)) ([23a41f0](https://github.com/dhis2/ui/commit/23a41f0)) + +## [2.0.2](https://github.com/dhis2/ui/compare/v2.0.1...v2.0.2) (2019-05-06) + + +### Bug Fixes + +* ensure that both bundles are built using prod mode ([#203](https://github.com/dhis2/ui/issues/203)) ([b3145c9](https://github.com/dhis2/ui/commit/b3145c9)) + +## [2.0.1](https://github.com/dhis2/ui/compare/v2.0.0...v2.0.1) (2019-05-06) + + +### Bug Fixes + +* allow buttons to be used without onclick handlers ([#202](https://github.com/dhis2/ui/issues/202)) ([ec474bc](https://github.com/dhis2/ui/commit/ec474bc)) + +# [2.0.0](https://github.com/dhis2/ui/compare/v1.1.3...v2.0.0) (2019-05-06) + + +### Features + +* version 2 of ui-core ([#183](https://github.com/dhis2/ui/issues/183)) ([6364f4c](https://github.com/dhis2/ui/commit/6364f4c)) + + +### BREAKING CHANGES + +* The component API has been revamped to be simpler and more consistent. See the documentation for a full list. +* This changes the `list` prop passed to DropdownButton and SplitButton to `component`, which enables any component to be used as a menu. +* Event handlers align with the JS API of being called with one argument, the Event. + +## [1.1.3](https://github.com/dhis2/ui/compare/v1.1.2...v1.1.3) (2019-03-25) + + +### Bug Fixes + +* use cli to release; remove packages ([#176](https://github.com/dhis2/ui/issues/176)) ([5b0e402](https://github.com/dhis2/ui/commit/5b0e402)) diff --git a/packages/core/d2.config.js b/packages/core/d2.config.js new file mode 100644 index 0000000000..a9c5a5e3ef --- /dev/null +++ b/packages/core/d2.config.js @@ -0,0 +1,11 @@ +module.exports = { + type: 'lib', + entryPoint: { + lib: 'src/index.js', + }, + docsite: { + jsdoc2md: { + 'module-index-format': 'none', + }, + }, +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000..1d134a266e --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,36 @@ +{ + "name": "@dhis2/ui-core", + "description": "Component primitives for DHIS2 user interfaces", + "version": "5.0.0", + "main": "build/cjs/lib.js", + "module": "build/es/lib.js", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/dhis2/ui-core.git" + }, + "author": "Viktor Varland ", + "license": "BSD-3-Clause", + "private": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "d2-app-scripts build" + }, + "peerDependencies": { + "react": "^16.8", + "react-dom": "^16.8", + "styled-jsx": "^3.2" + }, + "dependencies": { + "@dhis2/prop-types": "^1.5.0", + "@dhis2/ui-constants": "^5.0.0", + "@dhis2/ui-icons": "^5.0.0", + "@popperjs/core": "^2.1.0", + "classnames": "^2.2.6" + }, + "files": [ + "build" + ] +} diff --git a/packages/core/src/AlertBar/Action.js b/packages/core/src/AlertBar/Action.js new file mode 100644 index 0000000000..1d2e43bfea --- /dev/null +++ b/packages/core/src/AlertBar/Action.js @@ -0,0 +1,36 @@ +import React, { Component } from 'react' +import propTypes from '@dhis2/prop-types' +import { spacers } from '@dhis2/ui-constants' + +class Action extends Component { + onClick = event => { + this.props.onClick(event) + this.props.hide(event) + } + + render() { + return ( + + {this.props.label} + + + ) + } +} + +Action.propTypes = { + dataTest: propTypes.string.isRequired, + hide: propTypes.func.isRequired, + label: propTypes.string.isRequired, + onClick: propTypes.func.isRequired, +} + +export { Action } diff --git a/packages/core/src/AlertBar/Actions.js b/packages/core/src/AlertBar/Actions.js new file mode 100644 index 0000000000..a60e569709 --- /dev/null +++ b/packages/core/src/AlertBar/Actions.js @@ -0,0 +1,47 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { Action } from './Action.js' +import { spacers } from '@dhis2/ui-constants' + +const Actions = ({ actions, hide, dataTest }) => { + if (!actions) { + return null + } + + return ( +
+ {actions.map(action => ( + + ))} + +
+ ) +} + +const actionsPropType = propTypes.arrayWithLength( + 0, + 2, + propTypes.shape({ + label: propTypes.string.isRequired, + onClick: propTypes.func.isRequired, + }) +) + +Actions.propTypes = { + dataTest: propTypes.string.isRequired, + hide: propTypes.func.isRequired, + actions: actionsPropType, +} + +export { Actions, actionsPropType } diff --git a/packages/core/src/AlertBar/AlertBar.js b/packages/core/src/AlertBar/AlertBar.js new file mode 100644 index 0000000000..a988e1b382 --- /dev/null +++ b/packages/core/src/AlertBar/AlertBar.js @@ -0,0 +1,194 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React, { Component } from 'react' + +import { Actions, actionsPropType } from './Actions.js' +import { Dismiss } from './Dismiss.js' +import { Icon, iconPropType } from './Icon.js' +import { Message } from './Message.js' +import styles, { ANIMATION_TIME } from './AlertBar.styles.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {AlertBar.PropTypes} props + * + * @returns {React.Component} + * + * @example import { AlertBar } from @dhis2/ui-core + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/alertbar.md|Design system} + * @see Live demo: {@link /demo/?path=/story/alertbar--default|Storybook} + */ +class AlertBar extends Component { + // visible is used to control the show/hide animation + // hidden is used to stop rendering entirely after hide animation + state = { + visible: false, + hidden: false, + } + + componentDidMount() { + this.init() + } + + componentDidUpdate(_prevProps, prevState) { + // Only re-init when props change, ignore state changes + if ( + prevState.visible === this.state.visible && + prevState.hidden === this.state.hidden + ) { + this.init() + } + } + + componentWillUnmount() { + clearTimeout(this.displayTimeout) + clearTimeout(this.onHiddenTimeout) + } + + init() { + this.startTime = Date.now() + this.timeRemaining = this.props.duration + this.startDisplayTimeout() + this.show() + } + + startDisplayTimeout = () => { + if (this.shouldAutoHide()) { + this.displayTimeout = setTimeout(() => { + this.hide(null) + }, this.timeRemaining) + } + } + + stopDisplayTimeOut = () => { + if (this.shouldAutoHide()) { + const elapsedTime = Date.now() - this.startTime + this.timeRemaining = this.timeRemaining - elapsedTime + clearTimeout(this.displayTimeout) + } + } + + hide = event => { + clearTimeout(this.displayTimeout) + this.setState({ visible: false }) + + this.onHiddenTimeout = setTimeout(() => { + this.setState( + { hidden: true }, + () => this.props.onHidden && this.props.onHidden({}, event) + ) + }, ANIMATION_TIME) + } + + show() { + requestAnimationFrame(() => { + this.setState({ visible: true }) + }) + } + + shouldAutoHide() { + const { permanent, warning, critical } = this.props + return !(permanent || warning || critical) + } + + render() { + const { + className, + children, + success, + warning, + critical, + icon, + actions, + dataTest, + } = this.props + const { visible, hidden } = this.state + + if (hidden) { + return null + } + + const info = !critical && !success && !warning + + return ( +
+ + {children} + + + + +
+ ) + } +} + +const alertTypePropType = propTypes.mutuallyExclusive( + ['success', 'warning', 'critical'], + propTypes.bool +) + +AlertBar.defaultProps = { + duration: 8000, + dataTest: 'dhis2-uicore-alertbar', + icon: true, +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [children] - The message string for the alert + * @prop {string} [className] + * @prop {boolean} [success] - `success`, `warning`, and `critical` are + * mutually exclusive props. + * @prop {boolean} [warning] + * @prop {boolean} [critical] + * + * @prop {(Element|boolean)} [icon=true] + * + * @prop {number} [duration] + * @prop {boolean} [permanent] + * @prop {Array} [actions] An array of 0-2 action objects with the shape: `{ label: {string}, onClick: {function} }` + * @prop {function} [onHidden] + * @prop {string} [dataTest] + */ +AlertBar.propTypes = { + actions: actionsPropType, + children: propTypes.string, + className: propTypes.string, + critical: alertTypePropType, + dataTest: propTypes.string, + duration: propTypes.number, + icon: iconPropType, + permanent: propTypes.bool, + success: alertTypePropType, + warning: alertTypePropType, + onHidden: propTypes.func, +} + +export { AlertBar } diff --git a/packages/core/src/AlertBar/AlertBar.stories.e2e.js b/packages/core/src/AlertBar/AlertBar.stories.e2e.js new file mode 100644 index 0000000000..9140a8436f --- /dev/null +++ b/packages/core/src/AlertBar/AlertBar.stories.e2e.js @@ -0,0 +1,31 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { AlertBar } from './AlertBar.js' + +window.onHidden = window.Cypress && window.Cypress.cy.stub() + +storiesOf('AlertBar', module) + .add('Default', () => Default) + .add('Custom duration', () => ( + Custom duration + )) + .add('Permanent with actions', () => ( + {} }, + { label: 'Cancel', onClick: () => {} }, + ]} + > + With Actions + + )) + .add('Disabled icon', () => Message) + .add('Custom icon', () => ( + Custom icon}>Message + )) + .add('With message', () => With a message) + .add('With onHidden', () => ( + Message + )) + .add('Permanent', () => Message) diff --git a/packages/core/src/AlertBar/AlertBar.stories.js b/packages/core/src/AlertBar/AlertBar.stories.js new file mode 100644 index 0000000000..6bf1dc48c9 --- /dev/null +++ b/packages/core/src/AlertBar/AlertBar.stories.js @@ -0,0 +1,94 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { AlertBar } from './AlertBar.js' +import { AttachFile } from '@dhis2/ui-icons' + +const Wrapper = fn => ( +
+ {fn()} +
+) + +storiesOf('Component/Core/AlertBar', module) + .addDecorator(Wrapper) + .add('Default', () => Default - I will autohide) + .add('States', () => ( + + Default (info) + + Success + + + Warning + + + Critical + + + )) + .add('Auto hiding', () => ( + + Permanent never auto-hides + Warning never auto-hides + Critial never auto-hides + { + console.log('onHidden payload', payload) + console.log('onHidden event', event) + }} + > + Custom duration, hides after 2s + + { + console.log('onHidden payload', payload) + console.log('onHidden event', event) + }} + > + Default auto-hides after 8s + + + )) + .add('With actions', () => ( + {} }, + { label: 'Cancel', onClick: () => {} }, + ]} + > + With Actions + + )) + .add('Icons', () => ( + + Default icon + + No icon + + }> + Custom icon + + + )) + .add('Text overflow', () => ( + + Short text + + If the alert bar gets a ver long text, it will grow to a maximum + of 600px and the text will overflow across several lines. If + there are multiple AlertBars in a stack, they will all grow to + the size of the widest sibling. + + + )) diff --git a/packages/core/src/AlertBar/AlertBar.styles.js b/packages/core/src/AlertBar/AlertBar.styles.js new file mode 100644 index 0000000000..aab1b59dfb --- /dev/null +++ b/packages/core/src/AlertBar/AlertBar.styles.js @@ -0,0 +1,66 @@ +import css from 'styled-jsx/css' +import { colors, spacers, elevations } from '@dhis2/ui-constants' + +export const ANIMATION_TIME = 350 + +export default css` + div { + display: flex; + justify-content: space-between; + align-items: center; + + border-radius: 4px; + box-shadow: ${elevations.e300}; + + padding-top: ${spacers.dp12}; + padding-right: ${spacers.dp16}; + padding-bottom: ${spacers.dp12}; + padding-left: ${spacers.dp24}; + + margin-bottom: ${spacers.dp16}; + + max-width: 600px; + + font-size: 14px; + + transform: translateY(1000px); + transition: transform ${ANIMATION_TIME}ms cubic-bezier(0.4, 0, 0.6, 1); + + pointer-events: all; + } + + /* States */ + div.info { + background-color: ${colors.grey900}; + color: ${colors.white}; + } + div.info :global(path) { + fill: ${colors.white}; + } + div.success { + background-color: ${colors.green800}; + color: ${colors.white}; + } + div.success :global(path) { + fill: ${colors.white}; + } + div.warning { + background-color: ${colors.yellow300}; + color: ${colors.yellow900}; + } + div.warning :global(path) { + fill: ${colors.yellow900}; + } + div.critical { + background-color: ${colors.red800}; + color: ${colors.white}; + } + div.critical :global(path) { + fill: ${colors.white}; + } + + /* Animation */ + div.visible { + transform: translateY(0px); + } +` diff --git a/packages/core/src/AlertBar/Dismiss.js b/packages/core/src/AlertBar/Dismiss.js new file mode 100644 index 0000000000..4443c2077c --- /dev/null +++ b/packages/core/src/AlertBar/Dismiss.js @@ -0,0 +1,29 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { spacers } from '@dhis2/ui-constants' +import { Close } from '@dhis2/ui-icons' + +const Dismiss = ({ onClick, dataTest }) => ( +
+ + +
+) + +Dismiss.propTypes = { + dataTest: propTypes.string.isRequired, + onClick: propTypes.func.isRequired, +} + +export { Dismiss } diff --git a/packages/core/src/AlertBar/Icon.js b/packages/core/src/AlertBar/Icon.js new file mode 100644 index 0000000000..ddfa1b2a86 --- /dev/null +++ b/packages/core/src/AlertBar/Icon.js @@ -0,0 +1,52 @@ +import propTypes from '@dhis2/prop-types' +import React from 'react' + +import { StatusIcon } from '@dhis2/ui-icons' +import { spacers } from '@dhis2/ui-constants' + +const Icon = ({ icon, success, warning, critical, info, dataTest }) => { + if (icon === false) { + return null + } + + return ( +
+ {React.isValidElement(icon) ? ( + icon + ) : ( + + )} + +
+ ) +} + +const iconPropType = propTypes.oneOfType([propTypes.bool, propTypes.element]) +const alertStatePropType = propTypes.mutuallyExclusive( + ['success', 'warning', 'critical', 'info'], + propTypes.bool +) + +Icon.propTypes = { + dataTest: propTypes.string.isRequired, + critical: alertStatePropType, + icon: iconPropType, + info: alertStatePropType, + success: alertStatePropType, + warning: alertStatePropType, +} + +export { Icon, iconPropType } diff --git a/packages/core/src/AlertBar/Message.js b/packages/core/src/AlertBar/Message.js new file mode 100644 index 0000000000..82c0dd2347 --- /dev/null +++ b/packages/core/src/AlertBar/Message.js @@ -0,0 +1,20 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +const Message = ({ children }) => ( +
+ {children} + +
+) + +Message.propTypes = { + children: propTypes.string.isRequired, +} + +export { Message } diff --git a/packages/core/src/AlertStack/AlertStack.js b/packages/core/src/AlertStack/AlertStack.js new file mode 100644 index 0000000000..dc774613df --- /dev/null +++ b/packages/core/src/AlertStack/AlertStack.js @@ -0,0 +1,58 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { layers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {AlertStack.PropTypes} props + * @returns {React.Component} + * @example import { AlertStack } from '@dhis2/ui-core' + * @see Live demo: {@link /demo/?path=/story/alertstack--default|Storybook} + */ +const AlertStack = ({ className, children, dataTest }) => + createPortal( +
+ {children} + +
, + document.body + ) + +AlertStack.defaultProps = { + dataTest: 'dhis2-uicore-alertstack', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [className] + * @prop {Array.} [children] + * @prop {string} [dataTest] + */ +AlertStack.propTypes = { + children: propTypes.arrayOf(propTypes.element), + className: propTypes.string, + dataTest: propTypes.string, +} + +export { AlertStack } diff --git a/packages/core/src/AlertStack/AlertStack.stories.e2e.js b/packages/core/src/AlertStack/AlertStack.stories.e2e.js new file mode 100644 index 0000000000..fa215ac14a --- /dev/null +++ b/packages/core/src/AlertStack/AlertStack.stories.e2e.js @@ -0,0 +1,12 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { AlertStack } from './AlertStack.js' +import { AlertBar } from '../index.js' + +storiesOf('AlertStack', module).add('Default', () => ( + + Message + Message + Message + +)) diff --git a/packages/core/src/AlertStack/AlertStack.stories.js b/packages/core/src/AlertStack/AlertStack.stories.js new file mode 100644 index 0000000000..62e98ba7d2 --- /dev/null +++ b/packages/core/src/AlertStack/AlertStack.stories.js @@ -0,0 +1,19 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { AlertStack } from './AlertStack.js' +import { AlertBar } from '../index.js' + +storiesOf('Component/Core/AlertStack', module).add('Default', () => ( + + First notification - I am at the bottom + + Second notification + + + Third notification + + + Fourth notification - I am at the top + + +)) diff --git a/packages/core/src/Backdrop/Backdrop.js b/packages/core/src/Backdrop/Backdrop.js new file mode 100644 index 0000000000..44192b455c --- /dev/null +++ b/packages/core/src/Backdrop/Backdrop.js @@ -0,0 +1,88 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { Layer } from '../LayerContext/LayerContext.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @private + * @param {Object} PropTypes + * @returns {React.Component} + */ +const Backdrop = ({ + onClick, + transparent, + children, + zIndex, + className, + dataTest, +}) => { + return ( + + {zIndexComputed => ( +
onClick && onClick({}, event)} + data-test={dataTest} + > +
{ + // stop events from bubbling up through the + // children to the backdrop click handler + e.stopPropagation() + }} + > + {children} +
+ + + + +
+ )} +
+ ) +} + +Backdrop.defaultProps = { + dataTest: 'dhis2-uicore-backdrop', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {function} onClick + * @prop {boolean} transparent + * @prop {Node} children + * @prop {number} zIndex + * @prop {string} className + * @prop {string} [dataTest] + */ +Backdrop.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + transparent: propTypes.bool, + zIndex: propTypes.number, + onClick: propTypes.func, +} + +export { Backdrop } diff --git a/packages/core/src/Backdrop/Backdrop.stories.e2e.js b/packages/core/src/Backdrop/Backdrop.stories.e2e.js new file mode 100644 index 0000000000..fca5d2b922 --- /dev/null +++ b/packages/core/src/Backdrop/Backdrop.stories.e2e.js @@ -0,0 +1,13 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Backdrop } from './Backdrop.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Backdrop', module) + .add('With onClick', () => ) + .add('With children', () => ( + + I am a child + + )) diff --git a/packages/core/src/Box/Box.js b/packages/core/src/Box/Box.js new file mode 100644 index 0000000000..0656892b53 --- /dev/null +++ b/packages/core/src/Box/Box.js @@ -0,0 +1,63 @@ +import propTypes from '@dhis2/prop-types' +import React from 'react' + +/** + * @module + * + * @param {Box.PropTypes} props + * @returns {React.Component} + */ +export const Box = ({ + overflow, + height, + minHeight, + maxHeight, + width, + minWidth, + maxWidth, + children, + dataTest, +}) => ( +
+ {children} + +
+) + +Box.defaultProps = { + dataTest: 'dhis2-uicore-box', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [height] + * @prop {string} [minHeight] + * @prop {string} [maxHeight] + * @prop {string} [width] + * @prop {string} [minWidth] + * @prop {string} [maxWidth] + * @prop {string} [dataTest] + */ +Box.propTypes = { + children: propTypes.node, + dataTest: propTypes.string, + height: propTypes.string, + maxHeight: propTypes.string, + maxWidth: propTypes.string, + minHeight: propTypes.string, + minWidth: propTypes.string, + overflow: propTypes.string, + width: propTypes.string, +} diff --git a/packages/core/src/Box/Box.stories.e2e.js b/packages/core/src/Box/Box.stories.e2e.js new file mode 100644 index 0000000000..35c86c83f5 --- /dev/null +++ b/packages/core/src/Box/Box.stories.e2e.js @@ -0,0 +1,5 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Box } from './Box.js' + +storiesOf('Box', module).add('With children', () => I am a child) diff --git a/packages/core/src/Box/Box.stories.js b/packages/core/src/Box/Box.stories.js new file mode 100644 index 0000000000..a7ef7c1e93 --- /dev/null +++ b/packages/core/src/Box/Box.stories.js @@ -0,0 +1,35 @@ +import React from 'react' +import { Box } from './Box.js' + +export default { + title: 'Component/Core/Box', + component: Box, +} + +export const Default = () => I am a child in a Box. + +export const Height = () => I am a child in a Box. + +export const MaxHeight = () => ( + +

I am a tall child in a low Box.

+
+) + +export const MinHeight = () => ( + I am a child in a Box. +) + +export const Width = () => I am a child in a Box. + +export const MinWidth = () => I am a child in a Box. + +export const MaxWidth = () => I am a child in a Box. + +export const Overflow = () => ( + +

+ I am a tall child in a low Box, and my parent clips me +

+
+) diff --git a/packages/core/src/Button/Button.js b/packages/core/src/Button/Button.js new file mode 100644 index 0000000000..1f922a5b59 --- /dev/null +++ b/packages/core/src/Button/Button.js @@ -0,0 +1,157 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React, { Component, createRef } from 'react' + +import { sharedPropTypes } from '@dhis2/ui-constants' +import styles from './Button.styles.js' + +/** + * @module + * @param {Button.PropTypes} props + * + * @returns {React.Component} + * + * @example import { Button } from @dhis2/ui-core + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/button.md|Design system} + * @see Live demo: {@link /demo/?path=/story/button-basic--default|Storybook} + */ +export class Button extends Component { + buttonRef = createRef() + + componentDidMount() { + if (this.props.initialFocus) { + this.buttonRef.current.focus() + } + } + + handleClick = e => { + if (this.props.onClick) { + this.props.onClick(this.createHandlerPayload(), e) + } + } + + handleBlur = e => { + if (this.props.onBlur) { + this.props.onBlur(this.createHandlerPayload(), e) + } + } + + handleFocus = e => { + if (this.props.onFocus) { + this.props.onFocus(this.createHandlerPayload(), e) + } + } + + createHandlerPayload() { + return { + value: this.props.value, + name: this.props.name, + } + } + + render() { + const { + children, + className, + dataTest, + destructive, + disabled, + icon, + large, + name, + primary, + secondary, + small, + tabIndex, + type, + value, + } = this.props + + return ( + + ) + } +} + +Button.defaultProps = { + type: 'button', + dataTest: 'dhis2-uicore-button', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {Node} [children] The children to render in the button + * @prop {function} [onClick] The click handler + * @prop {function} [onBlur] + * @prop {function} [onFocus] + * + * @prop {string} [className] + * @prop {string} [name] + * @prop {string} [value] + * @prop {string} [tabIndex] + * @prop {boolean} [small] - `small` and `large` are mutually exclusive + * @prop {boolean} [large] + * @prop {string} [type=button] Type of button: `submit`, `reset`, or + * `button` + * + * @prop {boolean } [primary] - `primary`, `secondary`, and + * `destructive` are mutually exclusive boolean props + * @prop {boolean } [secondary] + * @prop {boolean } [destructive] + * + * @prop {boolean} [disabled] Disable the button + * @prop {Element} [icon] + * + * @prop {string} [dataTest] + * @prop {boolean} [initialFocus] Grants the button the initial focus + * state + */ +Button.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + destructive: sharedPropTypes.buttonVariantPropType, + disabled: propTypes.bool, + icon: propTypes.element, + initialFocus: propTypes.bool, + large: sharedPropTypes.sizePropType, + name: propTypes.string, + primary: sharedPropTypes.buttonVariantPropType, + secondary: sharedPropTypes.buttonVariantPropType, + small: sharedPropTypes.sizePropType, + tabIndex: propTypes.string, + type: propTypes.oneOf(['submit', 'reset', 'button']), + value: propTypes.string, + onBlur: propTypes.func, + onClick: propTypes.func, + onFocus: propTypes.func, +} + +export default Button diff --git a/packages/core/src/Button/Button.stories.e2e.js b/packages/core/src/Button/Button.stories.e2e.js new file mode 100644 index 0000000000..71d4c11d09 --- /dev/null +++ b/packages/core/src/Button/Button.stories.e2e.js @@ -0,0 +1,29 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import Button from './Button.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() +window.onBlur = window.Cypress && window.Cypress.cy.stub() +window.onFocus = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Button', module) + .add('With onClick', () => ( + + )) + .add('With initialFocus and onBlur', () => ( + + )) + .add('With onFocus', () => ( + + )) diff --git a/packages/core/src/Button/Button.stories.js b/packages/core/src/Button/Button.stories.js new file mode 100644 index 0000000000..c35aac2a3a --- /dev/null +++ b/packages/core/src/Button/Button.stories.js @@ -0,0 +1,86 @@ +import React from 'react' +import Button from './Button.js' + +const logger = ({ name, value }) => console.info(`${name}: ${value}`) + +export default { + title: 'Component/Core/Button', + component: Button, +} + +export const Basic = () => ( + +) + +export const Primary = () => ( + +) + +export const Secondary = () => ( + +) + +export const Destructive = () => ( + +) + +export const Disabled = () => ( + +) + +export const Small = () => ( + +) + +export const Large = () => ( + +) + +export const InitialFocus = () => ( + +) + +export const Icon = () => ( + + + + +)) diff --git a/packages/core/src/ButtonStrip/ButtonStrip.stories.js b/packages/core/src/ButtonStrip/ButtonStrip.stories.js new file mode 100644 index 0000000000..91b6892d08 --- /dev/null +++ b/packages/core/src/ButtonStrip/ButtonStrip.stories.js @@ -0,0 +1,43 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { ButtonStrip } from './ButtonStrip.js' +import { Button } from '../index.js' + +const Wrapper = fn => ( +
+ {fn()} +
+) + +storiesOf('Component/Core/ButtonStrip', module) + .addDecorator(Wrapper) + .add('Default', () => ( + + + + + + + )) + .add('Default - aligned middle', () => ( + + + + + + + )) + .add('Default - aligned right', () => ( + + + + + + + )) diff --git a/packages/core/src/Card/Card.js b/packages/core/src/Card/Card.js new file mode 100644 index 0000000000..120ff91ba0 --- /dev/null +++ b/packages/core/src/Card/Card.js @@ -0,0 +1,55 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { colors } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Card.PropTypes} props + * @returns {React.Component} + * @example import { Card } from '@dhis2/ui-core' + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/card.md|Design system} + * @see Live demo: {@link /demo/?path=/story/card--default|Storybook} + */ +const Card = ({ className, children, dataTest }) => ( +
+ {children} + + +
+) + +Card.defaultProps = { + dataTest: 'dhis2-uicore-card', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [className] + * @prop {Node} [children] + * @prop {string} [dataTest] + */ +Card.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +export { Card } diff --git a/packages/core/src/Card/Card.stories.e2e.js b/packages/core/src/Card/Card.stories.e2e.js new file mode 100644 index 0000000000..4e6917436c --- /dev/null +++ b/packages/core/src/Card/Card.stories.e2e.js @@ -0,0 +1,11 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Card } from './Card.js' + +storiesOf('Card', module).add('With children', () => ( + + I am a child + I am a child + I am a child + +)) diff --git a/packages/core/src/Card/Card.stories.js b/packages/core/src/Card/Card.stories.js new file mode 100644 index 0000000000..cc8166ec40 --- /dev/null +++ b/packages/core/src/Card/Card.stories.js @@ -0,0 +1,11 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Card } from './Card.js' + +const Wrapper = fn => ( +
{fn()}
+) + +storiesOf('Component/Core/Card', module) + .addDecorator(Wrapper) + .add('Default', () => ) diff --git a/packages/core/src/Checkbox/Checkbox.js b/packages/core/src/Checkbox/Checkbox.js new file mode 100644 index 0000000000..a631efc27c --- /dev/null +++ b/packages/core/src/Checkbox/Checkbox.js @@ -0,0 +1,233 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React, { Component, createRef } from 'react' + +import { Regular, Dense } from '@dhis2/ui-icons' +import { colors, theme, sharedPropTypes } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * + * @param {Checkbox.PropTypes} props + * @returns {React.Component} + * + * @example import { Checkbox } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/checkbox.md|Design system} + * @see Live demo: {@link /demo/?path=/story/checkbox--default|Storybook} + */ +class Checkbox extends Component { + ref = createRef() + + componentDidMount() { + if (this.props.initialFocus) { + this.ref.current.focus() + } + + this.setIndeterminate(this.props.indeterminate) + } + + componentDidUpdate(prevProps) { + if (prevProps.indeterminate !== this.props.indeterminate) { + this.setIndeterminate(this.props.indeterminate) + } + } + + setIndeterminate(indeterminate) { + this.ref.current.indeterminate = indeterminate + } + + handleChange = e => { + if (this.props.onChange) { + this.props.onChange(this.createHandlerPayload(), e) + } + } + + handleBlur = e => { + if (this.props.onBlur) { + this.props.onBlur(this.createHandlerPayload(), e) + } + } + + handleFocus = e => { + if (this.props.onFocus) { + this.props.onFocus(this.createHandlerPayload(), e) + } + } + + createHandlerPayload() { + return { + value: this.props.value, + name: this.props.name, + checked: !this.props.checked, + } + } + + render() { + const { + checked = false, + indeterminate = false, + className, + disabled, + error, + label, + name, + tabIndex, + valid, + value, + warning, + dense, + dataTest, + } = this.props + + const classes = cx({ + checked, + indeterminate, + disabled, + valid, + error, + warning, + }) + + return ( + + ) + } +} + +Checkbox.defaultProps = { + dataTest: 'dhis2-uicore-checkbox', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} value + * @prop {Node} [label] + * @prop {function} [onChange] - called with the signature `object, event` + * @prop {string} [name] + * @prop {string} [className] + * @prop {string} [tabIndex] + * + * @prop {boolean} [disabled] + * @prop {boolean} [checked] + * @prop {boolean} [indeterminate] + * @prop {boolean} [initialFocus] + * + * @prop {boolean} [valid] - `valid`, `warning`, and `error` are + * mutually exclusive + * @prop {boolean} [warning] + * @prop {boolean} [error] + * + * @prop {boolean} [dense] + * + * @prop {function} [onFocus] + * @prop {function} [onBlur] + * @prop {string} [dataTest] + */ + +const uniqueOnStatePropType = propTypes.mutuallyExclusive( + ['checked', 'indeterminate'], + propTypes.bool +) + +Checkbox.propTypes = { + checked: uniqueOnStatePropType, + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + disabled: propTypes.bool, + error: sharedPropTypes.statusPropType, + indeterminate: uniqueOnStatePropType, + initialFocus: propTypes.bool, + label: propTypes.node, + name: propTypes.string, + tabIndex: propTypes.string, + valid: sharedPropTypes.statusPropType, + value: propTypes.string, + warning: sharedPropTypes.statusPropType, + onBlur: propTypes.func, + onChange: propTypes.func, + onFocus: propTypes.func, +} + +export { Checkbox } diff --git a/packages/core/src/Checkbox/Checkbox.stories.e2e.js b/packages/core/src/Checkbox/Checkbox.stories.e2e.js new file mode 100644 index 0000000000..087e637722 --- /dev/null +++ b/packages/core/src/Checkbox/Checkbox.stories.e2e.js @@ -0,0 +1,56 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' +import { Checkbox } from './Checkbox.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() +window.onChange = window.Cypress && window.Cypress.cy.stub() +window.onBlur = window.Cypress && window.Cypress.cy.stub() +window.onFocus = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Checkbox', module) + .add('With onChange', () => ( + + )) + .add('With initialFocus and onBlur', () => ( + + )) + .add('With onFocus', () => ( + + )) + .add('Disabled with onClick', () => ( + + )) + .add('With label', () => ( + + )) + .add('With initialFocus', () => ( + + )) + .add('Indeterminate prop', () => ( + + )) + .add('No indeterminate prop', () => ( + + )) diff --git a/packages/core/src/Checkbox/Checkbox.stories.js b/packages/core/src/Checkbox/Checkbox.stories.js new file mode 100644 index 0000000000..ab930bf94c --- /dev/null +++ b/packages/core/src/Checkbox/Checkbox.stories.js @@ -0,0 +1,397 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' + +import { Checkbox } from './Checkbox.js' + +window.onChange = (payload, event) => { + console.log('onClick payload', payload) + console.log('onClick event', event) +} + +window.onFocus = (payload, event) => { + console.log('onFocus payload', payload) + console.log('onFocus event', event) +} + +window.onBlur = (payload, event) => { + console.log('onBlur payload', payload) + console.log('onBlur event', event) +} + +const onChange = (...args) => window.onChange(...args) +const onFocus = (...args) => window.onFocus(...args) +const onBlur = (...args) => window.onBlur(...args) + +storiesOf('Component/Core/Checkbox', module) + // Regular + .add('Default', () => ( + + )) + + .add('Focused unchecked', () => ( + <> + + + + )) + + .add('Focused checked', () => ( + <> + + + + )) + + .add('Checked', () => ( + + )) + + .add('Indeterminate', () => ( + + )) + + .add('Disabled', () => ( + <> + + + + )) + + .add('Valid', () => ( + <> + + + + )) + + .add('Warning', () => ( + <> + + + + )) + + .add('Error', () => ( + <> + + + + )) + + .add('Image label', () => ( + } + value="with-help" + onChange={onChange} + onFocus={onFocus} + onBlur={onBlur} + /> + )) + + // Dense + .add('Default - Dense', () => ( + + )) + + .add('Focused unchecked - Dense', () => ( + + )) + + .add('Focused checked - Dense', () => ( + + )) + + .add('Checked - Dense', () => ( + + )) + + .add('Indeterminate - Dense', () => ( + + )) + + .add('Disabled - Dense', () => ( + <> + + + + )) + + .add('Valid - Dense', () => ( + <> + + + + )) + + .add('Warning - Dense', () => ( + <> + + + + )) + + .add('Error - Dense', () => ( + <> + + + + )) + + .add('Image label - Dense', () => ( + } + value="with-help" + onChange={onChange} + onFocus={onFocus} + onBlur={onBlur} + /> + )) diff --git a/packages/core/src/Chip/Chip.js b/packages/core/src/Chip/Chip.js new file mode 100644 index 0000000000..b60e62429e --- /dev/null +++ b/packages/core/src/Chip/Chip.js @@ -0,0 +1,132 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { colors, theme, spacers } from '@dhis2/ui-constants' +import { Content } from './Content.js' +import { Icon } from './Icon.js' +import { Remove } from './Remove.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Chip.PropTypes} props + * @returns {React.Component} + * @example import { Chip } from @dhis2/ui-core + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/chip.md|Design system} + * @see Live demo: {@link /demo/?path=/story/chip--default|Storybook} + */ +const Chip = ({ + selected, + disabled, + dragging, + overflow, + className, + children, + onRemove, + onClick, + icon, + dataTest, +}) => ( + { + if (!disabled && onClick) { + onClick({}, e) + } + }} + className={cx(className, { + selected, + disabled, + dragging, + })} + data-test={dataTest} + > + + {children} + + + + +) + +Chip.defaultProps = { + dataTest: 'dhis2-uicore-chip', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [children] + * @prop {string} [className] + * @prop {Element} [icon] + * @prop {function} [onClick] + * @prop {function} [onRemove] + * @prop {boolean} [selected] + * @prop {boolean} [disabled] + * @prop {boolean} [dragging] + * @prop {boolean} [overflow] + * @prop {string} [dataTest] + */ +Chip.propTypes = { + children: propTypes.string, + className: propTypes.string, + dataTest: propTypes.string, + disabled: propTypes.bool, + dragging: propTypes.bool, + icon: propTypes.element, + overflow: propTypes.bool, + selected: propTypes.bool, + onClick: propTypes.func, + onRemove: propTypes.func, +} + +export { Chip } +export default Chip diff --git a/packages/core/src/Chip/Chip.stories.e2e.js b/packages/core/src/Chip/Chip.stories.e2e.js new file mode 100644 index 0000000000..c447eed9dc --- /dev/null +++ b/packages/core/src/Chip/Chip.stories.e2e.js @@ -0,0 +1,15 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import Chip from './Chip.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() +window.onRemove = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Chip', module) + .add('Default', () => Message) + .add('With onClick', () => Chippy) + .add('With onRemove', () => ( + Chipmunk + )) + .add('With children', () => I am a child) + .add('With icon', () => Icon}>Message) diff --git a/packages/core/src/Chip/Chip.stories.js b/packages/core/src/Chip/Chip.stories.js new file mode 100644 index 0000000000..a32c422be4 --- /dev/null +++ b/packages/core/src/Chip/Chip.stories.js @@ -0,0 +1,50 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import Chip from './Chip.js' + +window.onClick = (payload, event) => { + console.log('onClick payload', payload) + console.log('onClick event', event) +} + +window.onRemove = (payload, event) => { + console.log('onRemove payload', payload) + console.log('onRemove event', event) +} + +const onClick = (...args) => window.onClick(...args) +const onRemove = (...args) => window.onRemove(...args) + +storiesOf('Component/Core/Chip', module) + .add('Default', () => Chippy) + + .add('Selected', () => ( + + Chipmunk + + )) + + .add('Overflow', () => ( + + A super long chip which should definitely truncate + + )) + + .add('Removable', () => ( + + Chipmunk + + )) + + .add('Icon', () => ( + }> + With an icon + + )) + +const Globe = () => ( + + LGTM icon + + +) diff --git a/packages/core/src/Chip/Content.js b/packages/core/src/Chip/Content.js new file mode 100644 index 0000000000..ae85903d8e --- /dev/null +++ b/packages/core/src/Chip/Content.js @@ -0,0 +1,30 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { spacers } from '@dhis2/ui-constants' + +export const Content = ({ children, overflow }) => ( + + {children} + + + +) + +Content.propTypes = { + children: propTypes.oneOfType([propTypes.string, propTypes.number]), + overflow: propTypes.bool, +} diff --git a/packages/core/src/Chip/Icon.js b/packages/core/src/Chip/Icon.js new file mode 100644 index 0000000000..2fd1aa0ea2 --- /dev/null +++ b/packages/core/src/Chip/Icon.js @@ -0,0 +1,38 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { spacers } from '@dhis2/ui-constants' + +export const Icon = ({ icon, dataTest }) => { + if (!icon) { + return null + } + + return ( + + {icon} + + + + ) +} + +Icon.propTypes = { + dataTest: propTypes.string.isRequired, + /** the slot for an icon is 24x24px, bigger elements will be clipped */ + icon: propTypes.element, +} diff --git a/packages/core/src/Chip/Remove.js b/packages/core/src/Chip/Remove.js new file mode 100644 index 0000000000..9ef1ec280f --- /dev/null +++ b/packages/core/src/Chip/Remove.js @@ -0,0 +1,56 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { css, resolve } from 'styled-jsx/css' + +import { Cancel } from '@dhis2/ui-icons' +import { colors } from '@dhis2/ui-constants' + +const containerStyle = css` + span { + display: block; + height: 18px; + width: 18px; + margin-right: 4px; + } +` + +const removeIcon = resolve` + svg { + color: ${colors.grey700}; + height: 18px; + width: 18px; + cursor: pointer; + opacity: 1; + pointer-events: all; + } + + svg:hover { + opacity: 0.82; + } +` + +export const Remove = ({ onRemove, dataTest }) => { + if (!onRemove) { + return null + } + + return ( + { + e.stopPropagation() // stop onRemove from triggering onClick on container + onRemove({}, e) + }} + data-test={dataTest} + > + + {removeIcon.styles} + + + + ) +} + +Remove.propTypes = { + dataTest: propTypes.string.isRequired, + onRemove: propTypes.func, +} diff --git a/packages/core/src/CircularLoader/CircularLoader.js b/packages/core/src/CircularLoader/CircularLoader.js new file mode 100644 index 0000000000..1c524b454a --- /dev/null +++ b/packages/core/src/CircularLoader/CircularLoader.js @@ -0,0 +1,111 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React from 'react' + +import { theme, spacers, sharedPropTypes } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {CircularLoader.PropTypes} props + * @returns {React.Component} + * @example import { CircularLoader } from @dhis2/ui-core + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/loading.md|Design system} + * @see Live demo: {@link /demo/?path=/story/circularloader--default|Storybook} + */ +const CircularLoader = ({ small, large, className, dataTest }) => ( +
+ + + + +
+) + +CircularLoader.defaultProps = { + dataTest: 'dhis2-uicore-circularloader', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [className] + * @prop {boolean} [small] - `small` and `large` are mutually exclusive. + * @prop {boolean} [large] + * @prop {string} [dataTest] + */ +CircularLoader.propTypes = { + className: propTypes.string, + dataTest: propTypes.string, + large: sharedPropTypes.sizePropType, + small: sharedPropTypes.sizePropType, +} + +export { CircularLoader } diff --git a/packages/core/src/CircularLoader/CircularLoader.stories.js b/packages/core/src/CircularLoader/CircularLoader.stories.js new file mode 100644 index 0000000000..8267d89f34 --- /dev/null +++ b/packages/core/src/CircularLoader/CircularLoader.stories.js @@ -0,0 +1,11 @@ +import React from 'react' +import { CircularLoader } from './CircularLoader.js' + +export default { + title: 'Component/Core/CircularLoader', + component: CircularLoader, +} + +export const Default = () => +export const Small = () => +export const Large = () => diff --git a/packages/core/src/ComponentCover/ComponentCover.js b/packages/core/src/ComponentCover/ComponentCover.js new file mode 100644 index 0000000000..e4a24ee559 --- /dev/null +++ b/packages/core/src/ComponentCover/ComponentCover.js @@ -0,0 +1,51 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { layers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {ComponentCover.PropTypes} props + * @returns {React.Component} + * @example import { ComponentCover } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/componentcover--circularloader|Storybook} + */ +const ComponentCover = ({ children, className, dataTest }) => ( +
+ {children} + +
+) + +ComponentCover.defaultProps = { + dataTest: 'dhis2-uicore-componentcover', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [className] + * @prop {Node} [children] + * @prop {string} [dataTest] + */ +ComponentCover.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +export { ComponentCover } diff --git a/packages/core/src/ComponentCover/ComponentCover.stories.e2e.js b/packages/core/src/ComponentCover/ComponentCover.stories.e2e.js new file mode 100644 index 0000000000..2cf463ee8c --- /dev/null +++ b/packages/core/src/ComponentCover/ComponentCover.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { ComponentCover } from './ComponentCover.js' + +storiesOf('ComponentCover', module).add('With children', () => ( + I am a child +)) diff --git a/packages/core/src/ComponentCover/ComponentCover.stories.js b/packages/core/src/ComponentCover/ComponentCover.stories.js new file mode 100644 index 0000000000..48ef0f0ecf --- /dev/null +++ b/packages/core/src/ComponentCover/ComponentCover.stories.js @@ -0,0 +1,29 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { ComponentCover } from './ComponentCover.js' +import { CircularLoader, Card } from '../index.js' + +storiesOf('Utility/ComponentCover', module) + .add('CircularLoader', () => ( +
+ + + + +

Text behind the cover

+

Lorem ipsum

+
+ )) + + .add('Modal', () => ( +
+ +
+ Some text. +
+
+ +

Text behind the cover

+

Lorem ipsum

+
+ )) diff --git a/packages/core/src/CssReset/CssReset.js b/packages/core/src/CssReset/CssReset.js new file mode 100644 index 0000000000..6d1932e1dd --- /dev/null +++ b/packages/core/src/CssReset/CssReset.js @@ -0,0 +1,210 @@ +import React from 'react' + +import { theme } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @desc Normalize CSS + * - https://github.com/necolas/normalize.css + * - https://www.paulirish.com/2012/box-sizing-border-box-ftw/ + * @returns {React.Component} + * @example import { CssReset } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/cssreset--default|Storybook} + */ +const CssReset = () => ( + +) + +export { CssReset } diff --git a/packages/core/src/CssReset/CssReset.stories.js b/packages/core/src/CssReset/CssReset.stories.js new file mode 100644 index 0000000000..eb421ea5d7 --- /dev/null +++ b/packages/core/src/CssReset/CssReset.stories.js @@ -0,0 +1,28 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { CssReset } from './CssReset.js' + +// eslint-disable-next-line react/prop-types +const App = ({ children }) =>
{children}
+ +storiesOf('Utility/CssReset', module).add('Default', () => ( + + + +

+ The CssReset component injects a global normalization + stylesheet into the DOM that sets the DHIS2 application defaults. +

+ +

+ This also sets the font-family on the + body element to the DHIS2 font, which allows it to + trickle down to the components as well. +

+ +

+ Just include the component in your application, preferably as early + as possible to avoid FOUC.{' '} +

+
+)) diff --git a/packages/core/src/CssVariables/CssVariables.js b/packages/core/src/CssVariables/CssVariables.js new file mode 100644 index 0000000000..50e8d577c5 --- /dev/null +++ b/packages/core/src/CssVariables/CssVariables.js @@ -0,0 +1,72 @@ +import React from 'react' + +import propTypes from '@dhis2/prop-types' +import * as theme from '@dhis2/ui-constants' + +const toPrefixedThemeSection = themeSectionKey => + Object.entries(theme[themeSectionKey]).reduce((prefixed, [key, value]) => { + prefixed[`${themeSectionKey}-${key}`] = value + + return prefixed + }, {}) + +const toCustomPropertyString = themeSection => + Object.entries(themeSection) + .map(([key, value]) => `--${key}: ${value};`) + .join('\n') + +/** + * @module + * @description Injects our theme variables as CSS custom properties + * @param {CssVariables.PropTypes} props + * @returns {React.Component} + * @example import { CssVariables } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/cssvariables--default|Storybook} + */ +const CssVariables = ({ colors, theme, layers, spacers, elevations }) => { + const allowedProps = { colors, theme, layers, spacers, elevations } + const variables = Object.keys(allowedProps) + // Filter all props that are false + .filter(prop => allowedProps[prop]) + // Map props to corresponding theme section and prefixes keys with section name + .map(toPrefixedThemeSection) + // Map each section to a single string of css custom property declarations + .map(toCustomPropertyString) + // Join all the sections to a single string + .join('\n') + + return ( + + ) +} + +CssVariables.defaultProps = { + colors: false, + theme: false, + layers: false, + spacers: false, + elevations: false, +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {boolean} [colors] + * @prop {boolean} [theme] + * @prop {boolean} [layers] + * @prop {boolean} [spacers] + * @prop {boolean} [elevations] + */ +CssVariables.propTypes = { + colors: propTypes.bool, + elevations: propTypes.bool, + layers: propTypes.bool, + spacers: propTypes.bool, + theme: propTypes.bool, +} + +export { CssVariables } diff --git a/packages/core/src/CssVariables/CssVariables.stories.e2e.js b/packages/core/src/CssVariables/CssVariables.stories.e2e.js new file mode 100644 index 0000000000..3392488e99 --- /dev/null +++ b/packages/core/src/CssVariables/CssVariables.stories.e2e.js @@ -0,0 +1,47 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { CssVariables } from './CssVariables.js' + +storiesOf('CssVariables', module) + .add('With colors', () => ( + + +
+
+ )) + .add('With theme', () => ( + + +
+
+ )) + .add('With layers', () => ( + + +
+
+ )) + .add('With spacers', () => ( + + +
+
+ )) + .add('With elevations', () => ( + + +
+
+ )) diff --git a/packages/core/src/CssVariables/CssVariables.stories.js b/packages/core/src/CssVariables/CssVariables.stories.js new file mode 100644 index 0000000000..dfa437ca55 --- /dev/null +++ b/packages/core/src/CssVariables/CssVariables.stories.js @@ -0,0 +1,33 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { CssVariables } from './CssVariables.js' + +// eslint-disable-next-line react/prop-types +const App = ({ children }) =>
{children}
+ +storiesOf('Utility/CssVariables', module) + .add('Default', () => ( + + + +

By default no custom properties are inserted.

+
+ )) + + .add('All', () => ( + + + +

+ The sections of the theme that should be inserted can be toggled + with flags, which allows the theme variables to be used as + regular CSS custom properties. So this{' '} + text uses + the vanilla CSS{' '} + + custom properties + {' '} + set by the CssVariables component. +

+
+ )) diff --git a/packages/core/src/Divider/Divider.js b/packages/core/src/Divider/Divider.js new file mode 100644 index 0000000000..2de76e16b5 --- /dev/null +++ b/packages/core/src/Divider/Divider.js @@ -0,0 +1,51 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { colors, spacers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Divider.PropTypes} props + * @returns {React.Component} + * @example import { Divider } from @dhis2/ui-core + */ +const Divider = ({ className, dataTest, dense, margin }) => ( +
+ + +
+) + +Divider.defaultProps = { + dataTest: 'dhis2-uicore-divider', + margin: `${spacers.dp8} 0`, +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [className] + * @prop {string} [dataTest] + * @prop {bool} [dense] + * @prop {string} [margin="${spacers.dp8} 0"] - DEPRECATED: A CSS shorthand declaration for margin. If margin and dense are used at the same time, dense has precedence. + */ +Divider.propTypes = { + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + margin: propTypes.string, +} + +export { Divider } diff --git a/packages/core/src/Divider/Divider.stories.js b/packages/core/src/Divider/Divider.stories.js new file mode 100644 index 0000000000..58ee5a792a --- /dev/null +++ b/packages/core/src/Divider/Divider.stories.js @@ -0,0 +1,14 @@ +import React from 'react' + +import { Divider } from './Divider.js' + +export default { + title: 'Component/Core/Divider', + component: Divider, +} + +export const Default = () => + +export const Dense = () => + +export const Margin = () => diff --git a/packages/core/src/DropdownButton/DropdownButton.js b/packages/core/src/DropdownButton/DropdownButton.js new file mode 100644 index 0000000000..a0d69e755c --- /dev/null +++ b/packages/core/src/DropdownButton/DropdownButton.js @@ -0,0 +1,171 @@ +import propTypes from '@dhis2/prop-types' +import React, { Component } from 'react' +import { createPortal } from 'react-dom' +import { resolve } from 'styled-jsx/css' + +import { spacers } from '@dhis2/ui-constants' +import { ArrowDown, ArrowUp } from '@dhis2/ui-icons' + +import { Backdrop } from '../Backdrop/Backdrop.js' +import { Button } from '../Button/Button.js' +import { Popper } from '../Popper/Popper.js' +import { sharedPropTypes } from '@dhis2/ui-constants' + +const arrow = resolve` + margin-left: ${spacers.dp12}; +` + +/** + * @module + * @param {DropdownButton.PropTypes} props + * @returns {React.Component} + * @example import { DropdownButton } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/dropdownbutton-basic--default|Storybook} + */ +class DropdownButton extends Component { + state = { + open: false, + } + anchorRef = React.createRef() + + onToggle = ({ name, value }, event) => { + this.setState({ open: !this.state.open }, () => { + if (this.props.onClick) { + this.props.onClick( + { + name, + value, + open: this.state.open, + }, + event + ) + } + }) + } + + render() { + const { open } = this.state + const { + component, + children, + className, + destructive, + disabled, + icon, + large, + primary, + secondary, + small, + name, + value, + tabIndex, + type, + initialFocus, + dataTest, + } = this.props + + const ArrowIconComponent = open ? ArrowUp : ArrowDown + + return ( +
+ + + {open && + createPortal( + + + {component} + + , + document.body + )} + + {arrow.styles} + +
+ ) + } +} + +DropdownButton.defaultProps = { + dataTest: 'dhis2-uicore-dropdownbutton', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Element} [component] + * + * @prop {Node} [children] The children to render in the button + * @prop {function} [onClick] The click handler + * + * @prop {string} [className] + * @prop {string} [name] + * @prop {string} [value] + * @prop {string} [tabIndex] + * @prop {boolean} [small] - `small` and `large` are mutually exclusive + * @prop {boolean} [large] + * @prop {string} [type] Type of button: `submit`, `reset`, or + * `button` + * + * @prop {boolean } [primary] - `primary`, `secondary`, and + * `destructive` are mutually exclusive boolean props + * @prop {boolean } [secondary] + * @prop {boolean } [destructive] + * + * @prop {boolean} [disabled] Disable the button + * @prop {Element} [icon] + * + * @prop {boolean} [initialFocus] Grants the button the initial focus + * @prop {string} [dataTest] + */ +DropdownButton.propTypes = { + children: propTypes.node, + className: propTypes.string, + component: propTypes.element, + dataTest: propTypes.string, + destructive: sharedPropTypes.buttonVariantPropType, + disabled: propTypes.bool, + icon: propTypes.element, + initialFocus: propTypes.bool, + large: sharedPropTypes.sizePropType, + name: propTypes.string, + primary: sharedPropTypes.buttonVariantPropType, + secondary: sharedPropTypes.buttonVariantPropType, + small: sharedPropTypes.sizePropType, + tabIndex: propTypes.string, + type: propTypes.oneOf(['submit', 'reset', 'button']), + value: propTypes.string, + onClick: propTypes.func, +} + +export { DropdownButton } diff --git a/packages/core/src/DropdownButton/DropdownButton.stories.e2e.js b/packages/core/src/DropdownButton/DropdownButton.stories.e2e.js new file mode 100644 index 0000000000..ca81ef2a7f --- /dev/null +++ b/packages/core/src/DropdownButton/DropdownButton.stories.e2e.js @@ -0,0 +1,67 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { DropdownButton } from './DropdownButton.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() + +storiesOf('DropdownButton', module) + .add('Default', () => ( + Content} + > + Button + + )) + .add('With onClick', () => ( + Content} + > + Button + + )) + .add('With children', () => ( + Component} + > + I am a child + + )) + .add('With component', () => ( + I am a component} + /> + )) + .add('With icon', () => ( + I am a component} + icon={'Icon'} + /> + )) + .add('With initialFocus', () => ( + Content} + initialFocus + /> + )) + .add('Disabled with onClick', () => ( + Content} + onClick={window.onClick} + disabled + /> + )) diff --git a/packages/core/src/DropdownButton/DropdownButton.stories.js b/packages/core/src/DropdownButton/DropdownButton.stories.js new file mode 100644 index 0000000000..cab5d2ec8c --- /dev/null +++ b/packages/core/src/DropdownButton/DropdownButton.stories.js @@ -0,0 +1,76 @@ +import React from 'react' +import { DropdownButton } from './DropdownButton.js' + +export default { + title: 'Component/Core/DropdownButton', + component: DropdownButton, +} + +window.onClick = (payload, event) => { + console.log('onClick payload', payload) + console.log('onClick event', event) +} + +const onClick = (...args) => window.onClick(...args) + +const Simple = Simplest thing + +export const Default = () => ( + + Label me! + +) + +export const WithClick = () => ( + + Label me! + +) + +export const Primary = () => ( + + Label me! + +) + +export const Secondary = () => ( + + Label me! + +) + +export const Destructive = () => ( + + Label me! + +) + +export const Disabled = () => ( + + Label me! + +) + +export const Small = () => ( + + Label me! + +) + +export const Large = () => ( + + Label me! + +) + +export const WithMenu = () => ( + + Label me! + +) + +export const InitialFocus = () => ( + + Label me! + +) diff --git a/packages/core/src/Field/Field.js b/packages/core/src/Field/Field.js new file mode 100644 index 0000000000..7c78f327cf --- /dev/null +++ b/packages/core/src/Field/Field.js @@ -0,0 +1,42 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { spacers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Field.PropTypes} props + * @returns {React.Component} + * @example import { Field } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/field--default|Storybook} + */ +const Field = ({ children, className, dataTest }) => ( +
+ {children} + +
+) + +Field.defaultProps = { + dataTest: 'dhis2-uicore-field', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +Field.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +export { Field } diff --git a/packages/core/src/Field/Field.stories.e2e.js b/packages/core/src/Field/Field.stories.e2e.js new file mode 100644 index 0000000000..94a35faf42 --- /dev/null +++ b/packages/core/src/Field/Field.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Field } from './Field.js' + +storiesOf('Field', module).add('With children', () => ( + I am a child +)) diff --git a/packages/core/src/Field/Field.stories.js b/packages/core/src/Field/Field.stories.js new file mode 100644 index 0000000000..2f51e43dda --- /dev/null +++ b/packages/core/src/Field/Field.stories.js @@ -0,0 +1,29 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Input, Help } from '../index.js' +import { Field } from './Field.js' + +storiesOf('Component/Core/Field', module).add('Default', () => ( + <> + + { + console.log('Nothing happens') + }} + name="input" + label="An input" + /> + A helpful text + + + { + console.log('Nothing happens') + }} + name="input2" + label="An second input" + /> + A helpful text + + +)) diff --git a/packages/core/src/FieldSet/FieldSet.js b/packages/core/src/FieldSet/FieldSet.js new file mode 100644 index 0000000000..28c1f8fcc5 --- /dev/null +++ b/packages/core/src/FieldSet/FieldSet.js @@ -0,0 +1,41 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @module + * @param {FieldSet.PropTypes} props + * @returns {React.Component} + * @example import { FieldSet } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/fieldset--default} + */ +const FieldSet = ({ className, children, dataTest }) => ( +
+ {children} + +
+) + +FieldSet.defaultProps = { + dataTest: 'dhis2-uicore-fieldset', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +FieldSet.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +export { FieldSet } diff --git a/packages/core/src/FieldSet/FieldSet.stories.e2e.js b/packages/core/src/FieldSet/FieldSet.stories.e2e.js new file mode 100644 index 0000000000..a773f946a7 --- /dev/null +++ b/packages/core/src/FieldSet/FieldSet.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { FieldSet } from './FieldSet.js' + +storiesOf('FieldSet', module).add('With children', () => ( +
I am a child
+)) diff --git a/packages/core/src/FieldSet/FieldSet.stories.js b/packages/core/src/FieldSet/FieldSet.stories.js new file mode 100644 index 0000000000..180b13f679 --- /dev/null +++ b/packages/core/src/FieldSet/FieldSet.stories.js @@ -0,0 +1,49 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { FieldSet } from './FieldSet.js' +import { Field, Radio, Help, Legend } from '../index.js' + +const onChange = () => { + console.log('Radio was clicked, nothing else will happen') +} + +storiesOf('Component/Core/FieldSet', module) + .add('Default', () => ( +
+ It renders something in a fieldset element without the browser + styles +
+ )) + .add('Usage example - a radio button group with error status', () => ( +
+ Choose an option + + + + + + + + + + You really have to choose something! +
+ )) diff --git a/packages/core/src/FileInput/FileInput.js b/packages/core/src/FileInput/FileInput.js new file mode 100644 index 0000000000..3a621147d4 --- /dev/null +++ b/packages/core/src/FileInput/FileInput.js @@ -0,0 +1,174 @@ +import React, { createRef, Component } from 'react' +import cx from 'classnames' + +import propTypes from '@dhis2/prop-types' + +import { spacers, sharedPropTypes } from '@dhis2/ui-constants' +import { Upload, StatusIcon } from '@dhis2/ui-icons' + +import { Button } from '../Button/Button.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {FileInput.PropTypes} props + * @returns {React.Component} + * + * @example import { FileInput } from '@dhis2/ui-core' + */ +class FileInput extends Component { + ref = createRef() + + handleClick = () => { + this.ref.current.click() + } + + handleChange = e => { + if (this.props.onChange) { + this.props.onChange(this.createHandlerPayload(), e) + } + + // reset the file input so it won't prevent on-change + // if the same file was added in a second attempt + this.ref.current.value = '' + } + + handleBlur = e => { + if (this.props.onBlur) { + this.props.onBlur(this.createHandlerPayload(), e) + } + } + + handleFocus = e => { + if (this.props.onFocus) { + this.props.onFocus(this.createHandlerPayload(), e) + } + } + + createHandlerPayload() { + return { + files: this.ref.current.files, + name: this.props.name, + } + } + + render() { + const { + accept, + buttonLabel, + className, + dataTest, + disabled, + error, + initialFocus, + large, + multiple, + name, + small, + tabIndex, + valid, + warning, + } = this.props + + return ( +
+ + + + + +
+ ) + } +} + +FileInput.defaultProps = { + accept: '*', + dataTest: 'dhis2-uicore-fileinput', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [name] + * @prop {function} [onChange] - called with the signature `object, event` + * @prop {function} [onFocus] - called with the signature `object, event` + * @prop {function} [onBlur] - called with the signature `object, event` + * @prop {string} [buttonLabel] + * @prop {string} [className] + * @prop {string} [tabIndex] + * + * @prop {boolean} [disabled] + * @prop {boolean} [initialFocus] + * + * @prop {boolean} [valid] - `valid`, `warning` and `error` are mutually exclusive + * @prop {boolean} [warning] + * @prop {boolean} [error] + * + * @prop {boolean} [small] - `small` and `large` are mutually exclusive + * @prop {boolean} [large] + * + * @prop {string} [accept=*] - the `accept` attribute of the [native file input]{@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept} + * @prop {boolean} [multiple] - the `multiple` attribute of the [native file input]{@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#multiple} + * @prop {string} [dataTest] + */ +FileInput.propTypes = { + accept: propTypes.string, + buttonLabel: propTypes.string, + className: propTypes.string, + dataTest: propTypes.string, + disabled: propTypes.bool, + error: sharedPropTypes.statusPropType, + initialFocus: propTypes.bool, + large: sharedPropTypes.sizePropType, + multiple: propTypes.bool, + name: propTypes.string, + small: sharedPropTypes.sizePropType, + tabIndex: propTypes.string, + valid: sharedPropTypes.statusPropType, + warning: sharedPropTypes.statusPropType, + onBlur: propTypes.func, + onChange: propTypes.func, + onFocus: propTypes.func, +} + +export { FileInput } diff --git a/packages/core/src/FileInput/FileInput.stories.e2e.js b/packages/core/src/FileInput/FileInput.stories.e2e.js new file mode 100644 index 0000000000..8579f4e794 --- /dev/null +++ b/packages/core/src/FileInput/FileInput.stories.e2e.js @@ -0,0 +1,50 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { FileInput } from './FileInput.js' + +window.onBlur = window.Cypress && window.Cypress.cy.stub() +window.onFocus = window.Cypress && window.Cypress.cy.stub() + +const onChange = (payload, event) => { + // NOTE: if files is not transformed into an array, + // cypress will get an empty file list! + window.onChange( + { + ...payload, + files: [...payload.files], + }, + event + ) +} + +storiesOf('FileInput', module) + .add('With onChange', () => ( + + )) + .add('With onChange and multiple', () => ( + + )) + .add('With initialFocus and onBlur', () => ( + + )) + .add('With onFocus', () => ( + + )) diff --git a/packages/core/src/FileInput/FileInput.stories.js b/packages/core/src/FileInput/FileInput.stories.js new file mode 100644 index 0000000000..ee84bdddc7 --- /dev/null +++ b/packages/core/src/FileInput/FileInput.stories.js @@ -0,0 +1,92 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { FileInput } from './FileInput.js' + +const onChange = (payload, event) => { + console.log('onChange payload', payload) + console.log('onChange event', event) + + // NOTE: if files is not transformed into an array, + // cypress will get an empty file list! + window.onChange && + window.onChange( + { + ...payload, + files: [...payload.files], + }, + event + ) +} + +storiesOf('Component/Core/FileInput', module) + .add('Default', () => ( + + )) + .add('Multiple', () => ( + + )) + .add('Disabled', () => ( + + )) + .add('Sizes', () => ( + <> + + + + + )) + .add('Statuses', () => ( + <> + + + + + + )) diff --git a/packages/core/src/FileList/FileList.js b/packages/core/src/FileList/FileList.js new file mode 100644 index 0000000000..098cb1ea70 --- /dev/null +++ b/packages/core/src/FileList/FileList.js @@ -0,0 +1,43 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @module + * @param {FileList.PropTypes} props + * @returns {React.Component} + * + * @example import { FileList } from '@dhis2/ui-core' + */ +const FileList = ({ children, className, dataTest }) => ( +
+ {children} + +
+) + +FileList.defaultProps = { + dataTest: 'dhis2-uicore-filelist', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [className] + * @prop {FileListPlaceholder|FileListItem|Array.} [children] + * @prop {string} [dataTest] + */ +FileList.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +export { FileList } diff --git a/packages/core/src/FileList/FileList.stories.e2e.js b/packages/core/src/FileList/FileList.stories.e2e.js new file mode 100644 index 0000000000..4fd03a093e --- /dev/null +++ b/packages/core/src/FileList/FileList.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { FileList } from './FileList.js' + +storiesOf('FileList', module).add('With children', () => ( + I am a child +)) diff --git a/packages/core/src/FileListItem/FileListItem.js b/packages/core/src/FileListItem/FileListItem.js new file mode 100644 index 0000000000..a9e920193e --- /dev/null +++ b/packages/core/src/FileListItem/FileListItem.js @@ -0,0 +1,125 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { colors, spacers } from '@dhis2/ui-constants' +import { AttachFile, Loading } from '@dhis2/ui-icons' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {FileListItem.PropTypes} props + * @returns {React.Component} + * + * @example import { FileListItem } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/fileinput.md|Design system} + * @see Live demo: {@link /demo/?path=/story/fileinputfield--file-list|Storybook} + */ +const FileListItem = ({ + className, + label, + onRemove, + removeText, + loading, + onCancel, + cancelText, + dataTest, +}) => ( +

+ {loading ? : } + + + {label} + + {loading && onCancel && cancelText && ( + onCancel({}, event)} + data-test={`${dataTest}-cancel`} + > + {cancelText} + + )} + + {!loading && ( + onRemove({}, event)} + data-test={`${dataTest}-remove`} + > + {removeText} + + )} + + + +

+) + +FileListItem.defaultProps = { + dataTest: 'dhis2-uicore-filelistitem', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [label] + * @prop {function} onRemove + * @prop {string} [removeText] + * @prop {string} [className] + * @prop {boolean} [loading] + * @prop {function} [onCancel] + * @prop {string} [cancelText] + * @prop {string} [dataTest] + */ +FileListItem.propTypes = { + onRemove: propTypes.func.isRequired, + cancelText: propTypes.string, + className: propTypes.string, + dataTest: propTypes.string, + label: propTypes.string, + loading: propTypes.bool, + removeText: propTypes.string, + onCancel: propTypes.func, +} + +export { FileListItem } diff --git a/packages/core/src/FileListItem/FileListItem.stories.e2e.js b/packages/core/src/FileListItem/FileListItem.stories.e2e.js new file mode 100644 index 0000000000..e5b0dc86a6 --- /dev/null +++ b/packages/core/src/FileListItem/FileListItem.stories.e2e.js @@ -0,0 +1,40 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { FileListItem } from './FileListItem.js' + +window.onRemove = window.Cypress && window.Cypress.cy.stub() +window.onCancel = window.Cypress && window.Cypress.cy.stub() + +storiesOf('FileListItem', module) + .add('With onRemove', () => ( + + )) + .add('Loading with onCancel', () => ( + + )) + .add('Loading with onCancel and cancelText', () => ( + {}} + onRemove={() => {}} + cancelText="Cancel" + /> + )) + .add('With label', () => ( + {}} /> + )) + .add('With removeText', () => ( + {}} /> + )) diff --git a/packages/core/src/FileListPlaceholder/FileListPlaceholder.js b/packages/core/src/FileListPlaceholder/FileListPlaceholder.js new file mode 100644 index 0000000000..db4016183d --- /dev/null +++ b/packages/core/src/FileListPlaceholder/FileListPlaceholder.js @@ -0,0 +1,44 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { colors, spacers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {FileListPlaceholder.PropTypes} props + * @returns {React.Component} + * + * @example import { FileListPlaceholder } from '@dhis2/ui-core' + */ +const FileListPlaceholder = ({ children, dataTest }) => ( +

+ {children} + +

+) + +FileListPlaceholder.defaultProps = { + dataTest: 'dhis2-uicore-filelistplaceholder', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [children] + * @prop {string} [dataTest] + */ +FileListPlaceholder.propTypes = { + children: propTypes.string, + dataTest: propTypes.string, +} + +export { FileListPlaceholder } diff --git a/packages/core/src/FileListPlaceholder/FileListPlaceholder.stories.e2e.js b/packages/core/src/FileListPlaceholder/FileListPlaceholder.stories.e2e.js new file mode 100644 index 0000000000..9fdc2492fb --- /dev/null +++ b/packages/core/src/FileListPlaceholder/FileListPlaceholder.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { FileListPlaceholder } from './FileListPlaceholder.js' + +storiesOf('FileListPlaceholder', module).add('With children', () => ( + I am a child +)) diff --git a/packages/core/src/Help/Help.js b/packages/core/src/Help/Help.js new file mode 100644 index 0000000000..4906675047 --- /dev/null +++ b/packages/core/src/Help/Help.js @@ -0,0 +1,76 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React from 'react' + +import { spacers, theme, sharedPropTypes } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Help.PropTypes} props + * @returns {React.Component} + * @example import { Help } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/help--default|Storybook} + */ +const Help = ({ children, valid, error, warning, className, dataTest }) => ( +

+ {children} + + +

+) + +Help.defaultProps = { + dataTest: 'dhis2-uicore-help', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [children] + * @prop {string} [className] + * @prop {boolean} [valid] - `valid`, `warning`, and `error`, are mutually exclusive + * @prop {boolean} [warning] + * @prop {boolean} [error] + * @prop {string} [dataTest] + */ +Help.propTypes = { + children: propTypes.string, + className: propTypes.string, + dataTest: propTypes.string, + error: sharedPropTypes.statusPropType, + valid: sharedPropTypes.statusPropType, + warning: sharedPropTypes.statusPropType, +} + +export { Help } diff --git a/packages/core/src/Help/Help.stories.e2e.js b/packages/core/src/Help/Help.stories.e2e.js new file mode 100644 index 0000000000..14fa05802c --- /dev/null +++ b/packages/core/src/Help/Help.stories.e2e.js @@ -0,0 +1,5 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Help } from './Help.js' + +storiesOf('Help', module).add('With children', () => I am a child) diff --git a/packages/core/src/Help/Help.stories.js b/packages/core/src/Help/Help.stories.js new file mode 100644 index 0000000000..7b563154cf --- /dev/null +++ b/packages/core/src/Help/Help.stories.js @@ -0,0 +1,17 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Help } from './Help.js' + +storiesOf('Component/Core/Help', module) + .add('Default', () => Allow me to be of assistance) + + .add('Status: Warning', () => ( + Allow me to be of assistance + )) + .add('Status: Valid', () => Allow me to be of assistance) + .add('Status: Error', () => Allow me to be of assistance) + .add('Text overflow', () => ( +
+ I take up more space than my container +
+ )) diff --git a/packages/core/src/Input/Input.js b/packages/core/src/Input/Input.js new file mode 100644 index 0000000000..7d9459c52b --- /dev/null +++ b/packages/core/src/Input/Input.js @@ -0,0 +1,245 @@ +import React, { Component } from 'react' +import css from 'styled-jsx/css' +import cx from 'classnames' + +import propTypes from '@dhis2/prop-types' +import { theme, colors, spacers, sharedPropTypes } from '@dhis2/ui-constants' +import { StatusIcon } from '@dhis2/ui-icons' + +const styles = css` + .input { + display: flex; + align-items: center; + } + + input { + box-sizing: border-box; + + font-size: 14px; + line-height: 16px; + user-select: text; + + color: ${colors.grey900}; + background-color: white; + + padding: 12px 11px 10px; + + outline: 0; + border: 1px solid ${colors.grey500}; + border-radius: 3px; + box-shadow: inset 0 1px 2px 0 rgba(48, 54, 60, 0.1); + text-overflow: ellipsis; + } + + input.dense { + padding: 8px 11px 6px; + } + + input:focus { + border-color: ${colors.teal400}; + } + + input.valid { + border-color: ${theme.valid}; + } + + input.warning { + border-color: ${theme.warning}; + } + + input.error { + border-color: ${theme.error}; + } + + input.read-only { + background-color: ${colors.grey100}; + border-color: ${colors.grey500}; + cursor: text; + } + + input.disabled { + background-color: ${colors.grey100}; + border-color: ${colors.grey500}; + color: ${theme.disabled}; + cursor: not-allowed; + } + + .status-icon { + flex-shrink: 0; + flex-grow: 0; + margin-left: ${spacers.dp4}; + } +` + +/** + * @module + * @param {Input.PropTypes} props + * @returns {React.Component} + * + * @example import { Input } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/inputfield.md|Design system} + * @see Live demo: {@link /demo/?path=/story/inputfield--default|Storybook} + */ +export class Input extends Component { + inputRef = React.createRef() + + componentDidMount() { + if (this.props.initialFocus) { + this.inputRef.current.focus() + } + } + + handleChange = e => { + if (this.props.onChange) { + this.props.onChange(this.createHandlerPayload(e), e) + } + } + + handleBlur = e => { + if (this.props.onBlur) { + this.props.onBlur(this.createHandlerPayload(e), e) + } + } + + handleFocus = e => { + if (this.props.onFocus) { + this.props.onFocus(this.createHandlerPayload(e), e) + } + } + + createHandlerPayload(e) { + return { + value: e.target.value, + name: this.props.name, + } + } + + render() { + const { + className, + type, + dense, + disabled, + readOnly, + placeholder, + name, + valid, + error, + warning, + loading, + value, + tabIndex, + dataTest, + } = this.props + + return ( +
+ + +
+ +
+ + + +
+ ) + } +} + +Input.defaultProps = { + type: 'text', + dataTest: 'dhis2-uicore-input', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} name + * @prop {string} [type=text] + * @prop {function} [onChange] - called with the signature `object, event` + * @prop {function} [onBlur] + * @prop {function} [onFocus] + * @prop {string} [className] + * @prop {string} [placeholder] + * @prop {string} [value] + * @prop {string} [tabIndex] + * + * @prop {boolean} [disabled] + * @prop {boolean} [readOnly] + * @prop {boolean} [dense] - Compact mode + * @prop {boolean} [initialFocus] + * + * @prop {boolean} [valid] - `valid`, `warning`, `error`, and `loading` + * are mutually exclusive + * @prop {boolean} [warning] + * @prop {boolean} [error] + * @prop {boolean} [loading] + * @prop {string} [dataTest] + */ +Input.propTypes = { + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + disabled: propTypes.bool, + error: sharedPropTypes.statusPropType, + initialFocus: propTypes.bool, + loading: propTypes.bool, + name: propTypes.string, + placeholder: propTypes.string, + readOnly: propTypes.bool, + tabIndex: propTypes.string, + type: propTypes.oneOf([ + 'text', + 'number', + 'password', + 'email', + 'url', + 'tel', + 'date', + 'datetime', + 'datetime-local', + 'month', + 'week', + 'time', + 'search', + ]), + valid: sharedPropTypes.statusPropType, + value: propTypes.string, + warning: sharedPropTypes.statusPropType, + onBlur: propTypes.func, + onChange: propTypes.func, + onFocus: propTypes.func, +} diff --git a/packages/core/src/Input/Input.stories.e2e.js b/packages/core/src/Input/Input.stories.e2e.js new file mode 100644 index 0000000000..64bc595b64 --- /dev/null +++ b/packages/core/src/Input/Input.stories.e2e.js @@ -0,0 +1,40 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' +import { Input } from './Input.js' + +window.onChange = window.Cypress && window.Cypress.cy.stub() +window.onBlur = window.Cypress && window.Cypress.cy.stub() +window.onFocus = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Input', module) + .add('With onChange', () => ( + + )) + .add('With initialFocus and onBlur', () => ( + + )) + .add('With onFocus', () => ( + + )) + .add('With initialFocus', () => ( + + )) + .add('With disabled', () => ( + + )) diff --git a/packages/core/src/Label/Label.js b/packages/core/src/Label/Label.js new file mode 100644 index 0000000000..b2a4cda342 --- /dev/null +++ b/packages/core/src/Label/Label.js @@ -0,0 +1,78 @@ +import React from 'react' +import cx from 'classnames' +import css from 'styled-jsx/css' + +import propTypes from '@dhis2/prop-types' + +import { Required } from '../Required/Required.js' + +const styles = css` + label { + display: block; + box-sizing: border-box; + font-size: 14px; + line-height: 24px; + padding: 0; + } + + .disabled { + cursor: not-allowed; + } +` + +const constructClassName = ({ disabled, className }) => + cx(className, { + disabled: disabled, + }) + +/** + * @module + * @param {Label.PropTypes} props + * @returns {React.Component} + * + * @example import { Label } from '@dhis2/ui-core' + */ +export const Label = ({ + htmlFor, + children, + required, + disabled, + className, + dataTest, +}) => ( + +) + +Label.defaultProps = { + dataTest: 'dhis2-uicore-label', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [htmlFor] + * @prop {string} [children] + * @prop {string} [className] + * @prop {boolean} [required] + * @prop {boolean} [disabled] + * @prop {string} [dataTest] + */ +Label.propTypes = { + children: propTypes.string, + className: propTypes.string, + dataTest: propTypes.string, + disabled: propTypes.bool, + htmlFor: propTypes.string, + required: propTypes.bool, +} diff --git a/packages/core/src/Label/Label.stories.e2e.js b/packages/core/src/Label/Label.stories.e2e.js new file mode 100644 index 0000000000..32deb4d5f4 --- /dev/null +++ b/packages/core/src/Label/Label.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Label } from './Label.js' + +storiesOf('Label', module) + .add('With children', () => ) + .add('With required', () => ) diff --git a/packages/core/src/LayerContext/LayerContext.js b/packages/core/src/LayerContext/LayerContext.js new file mode 100644 index 0000000000..dd8a49f377 --- /dev/null +++ b/packages/core/src/LayerContext/LayerContext.js @@ -0,0 +1,62 @@ +import React, { useContext } from 'react' + +import propTypes from '@dhis2/prop-types' +import { layers } from '@dhis2/ui-constants' + +const LayerContext = React.createContext(0) + +const getStackedLayer = (zIndex, context) => { + // Keep alert layer constant + if (zIndex === layers.alert) { + return layers.alert + } + + // Differentiate between a stacked blocking and applicationTop layer + const layerIncrement = zIndex === layers.blocking ? 2 : 1 + const layer = context + layerIncrement + + // stay within stack layer boundaries defined by the design system + // https://github.com/dhis2/design-system/blob/master/principles/spacing-alignment.md#stacking + if (layer >= layers.alert) { + return layers.alert - 1 + } + + return layer +} + +const useLayer = zIndex => { + const context = useContext(LayerContext) + + if (context) return getStackedLayer(zIndex, context) + + return zIndex +} + +/** + * @module + * @private + * @param {Layer.PropTypes} props + * @returns {React.Component} + */ +const Layer = ({ children, zIndex }) => { + const newLayer = useLayer(zIndex) + + return ( + + {children(newLayer)} + + ) +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {function} children + * @prop {number} zIndex + */ +Layer.propTypes = { + children: propTypes.func.isRequired, + zIndex: propTypes.number, +} + +export { Layer, useLayer } diff --git a/packages/core/src/Legend/Legend.js b/packages/core/src/Legend/Legend.js new file mode 100644 index 0000000000..f87c9dc16e --- /dev/null +++ b/packages/core/src/Legend/Legend.js @@ -0,0 +1,53 @@ +import React from 'react' + +import propTypes from '@dhis2/prop-types' +import { colors } from '@dhis2/ui-constants' + +import { Required } from '../Required/Required.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Legend.PropTypes} props + * @returns {React.Component} + * + * @example import { Legend } from '@dhis2/ui-core' + * + * @see Live demo: {@link /demo/?path=/story/legend--default|Storybook} + */ +const Legend = ({ className, children, required, dataTest }) => ( + + {children} + + {required && } + + + +) + +Legend.defaultProps = { + dataTest: 'dhis2-uicore-legend', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * @prop {string} [className] + * @prop {boolean} [required] + * @prop {string} [dataTest] + */ +Legend.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + required: propTypes.bool, +} + +export { Legend } diff --git a/packages/core/src/Legend/Legend.stories.e2e.js b/packages/core/src/Legend/Legend.stories.e2e.js new file mode 100644 index 0000000000..d76ecfd472 --- /dev/null +++ b/packages/core/src/Legend/Legend.stories.e2e.js @@ -0,0 +1,11 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Legend } from './Legend.js' + +storiesOf('Legend', module) + .add('With content and required', () => ( + + I am wrapped in a legend which has some styling + + )) + .add('With children', () => I am a child) diff --git a/packages/core/src/Legend/Legend.stories.js b/packages/core/src/Legend/Legend.stories.js new file mode 100644 index 0000000000..8aa366e1f1 --- /dev/null +++ b/packages/core/src/Legend/Legend.stories.js @@ -0,0 +1,13 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Legend } from './Legend.js' + +storiesOf('Component/Core/Legend', module) + .add('Default', () => ( + I am wrapped in a legend which has some styling + )) + .add('Required', () => ( + + I am wrapped in a legend which has some styling + + )) diff --git a/packages/core/src/LinearLoader/LinearLoader.js b/packages/core/src/LinearLoader/LinearLoader.js new file mode 100644 index 0000000000..32b7f70ca4 --- /dev/null +++ b/packages/core/src/LinearLoader/LinearLoader.js @@ -0,0 +1,86 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { theme, spacers } from '@dhis2/ui-constants' + +const Progress = ({ amount }) => { + return ( +
+ + +
+ ) +} + +Progress.propTypes = { + amount: propTypes.number.isRequired, +} + +/** + * @module + * @param {LinearLoader.PropTypes} props + * @returns {React.Component} + * + * @example import { LinearLoader } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/loading.md|Design system} + * @see Live demo: {@link /demo/?path=/story/linearloader--determinate|Storybook} + */ +const LinearLoader = ({ amount, width, margin, className, dataTest }) => { + return ( +
+ + + + +
+ ) +} + +LinearLoader.defaultProps = { + margin: spacers.dp12, + width: '300px', + dataTest: 'dhis2-uicore-linearloader', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [className] + * @prop {number} [amount] - The progression in percent without the '%' sign + * @prop {string} [margin=spacers.dp12] - The margin around the loader, can be a full shorthand + * @prop {string} [width=300px] - The width of the entire indicator, e.g. '100%' or '300px' + * @prop {string} [dataTest] + */ +LinearLoader.propTypes = { + amount: propTypes.number.isRequired, + className: propTypes.string, + dataTest: propTypes.string, + margin: propTypes.string, + width: propTypes.string, +} + +export { LinearLoader } diff --git a/packages/core/src/LinearLoader/LinearLoader.stories.js b/packages/core/src/LinearLoader/LinearLoader.stories.js new file mode 100644 index 0000000000..ed1158d6ca --- /dev/null +++ b/packages/core/src/LinearLoader/LinearLoader.stories.js @@ -0,0 +1,24 @@ +import React from 'react' +import { LinearLoader } from './LinearLoader.js' +import { ScreenCover, ComponentCover } from '../index.js' + +export default { + title: 'Component/Core/LinearLoader', + component: LinearLoader, +} + +export const Determinate = () => + +export const OverlayPage = () => ( + + + +) + +export const OverlayComponent = () => ( +
+ + + +
+) diff --git a/packages/core/src/Logo/Logo.js b/packages/core/src/Logo/Logo.js new file mode 100644 index 0000000000..92eccff2b4 --- /dev/null +++ b/packages/core/src/Logo/Logo.js @@ -0,0 +1,98 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { LogoSvg } from './LogoSvg' +import { LogoIconSvg } from './LogoIconSvg' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/* + * This should likely not live in ui-core, but in ui-widgets + */ + +/** + * @module + * @param {Logo.PropTypes} props + * @returns {React.Component} + */ + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [className] + * @prop {string} [dataTest] + */ + +/* + * These are official colors for dhis2 logos. + * + * They are isolated here and not in @dhis2/ui-constants as they should not be + * shared with any other components. + * + * https://github.com/dhis2/dhis2-identity + * + */ +const blue = '#0080d4' +const white = '#ffffff' +const dark = '#212225' + +export const LogoIcon = ({ className, dataTest }) => ( + +) + +LogoIcon.defaultProps = { + dataTest: 'dhis2-uicore-logoicon', +} + +LogoIcon.propTypes = { + className: propTypes.string, + dataTest: propTypes.string, +} + +export const LogoIconWhite = ({ className, dataTest }) => ( + +) + +LogoIconWhite.defaultProps = { + dataTest: 'dhis2-uicore-logoiconwhite', +} + +LogoIconWhite.propTypes = { + className: propTypes.string, + dataTest: propTypes.string, +} + +export const Logo = ({ className, dataTest }) => ( + +) + +Logo.defaultProps = { + dataTest: 'dhis2-uicore-logo', +} + +Logo.propTypes = { + className: propTypes.string, + dataTest: propTypes.string, +} + +export const LogoWhite = ({ className, dataTest }) => ( + +) + +LogoWhite.defaultProps = { + dataTest: 'dhis2-uicore-logowhite', +} + +LogoWhite.propTypes = { + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/Logo/Logo.stories.js b/packages/core/src/Logo/Logo.stories.js new file mode 100644 index 0000000000..5acd1fdd54 --- /dev/null +++ b/packages/core/src/Logo/Logo.stories.js @@ -0,0 +1,29 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Logo, LogoWhite, LogoIcon, LogoIconWhite } from './Logo.js' + +const Wrapper = fn =>
{fn()}
+ +// eslint-disable-next-line react/prop-types +const Background = ({ children }) => ( +
{children}
+) + +storiesOf('Component/Core/Logo', module) + .addDecorator(Wrapper) + + .add('Logo', () => ) + + .add('Logo White', () => ( + + + + )) + + .add('Logo Icon', () => ) + + .add('Logo Icon White', () => ( + + + + )) diff --git a/packages/core/src/Logo/LogoIconSvg.js b/packages/core/src/Logo/LogoIconSvg.js new file mode 100644 index 0000000000..5a24493ef8 --- /dev/null +++ b/packages/core/src/Logo/LogoIconSvg.js @@ -0,0 +1,33 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +export function LogoIconSvg({ iconColor, className, dataTest }) { + return ( + + + + + + + ) +} + +LogoIconSvg.propTypes = { + iconColor: propTypes.string.isRequired, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/Logo/LogoSvg.js b/packages/core/src/Logo/LogoSvg.js new file mode 100644 index 0000000000..d25deb676c --- /dev/null +++ b/packages/core/src/Logo/LogoSvg.js @@ -0,0 +1,60 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +export function LogoSvg({ iconColor, textColor, className, dataTest }) { + return ( + + + + + + + + + + + + ) +} + +LogoSvg.propTypes = { + iconColor: propTypes.string.isRequired, + textColor: propTypes.string.isRequired, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/Menu/Menu.js b/packages/core/src/Menu/Menu.js new file mode 100644 index 0000000000..82a1ad3e46 --- /dev/null +++ b/packages/core/src/Menu/Menu.js @@ -0,0 +1,66 @@ +import React from 'react' + +import propTypes from '@dhis2/prop-types' +import { spacers } from '@dhis2/ui-constants' + +import { Card } from '../Card/Card.js' +import { MenuList } from '../MenuList/MenuList.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Menu.PropTypes} + * @returns {React.Component} + * + * @example import { Menu } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/menu.md|Design system} + * @see Live demo: {@link /demo/?path=/story/menu--default|Storybook} + */ +const Menu = ({ children, className, dataTest, maxWidth }) => ( +
+ +
+ {children} +
+
+ + + +
+) + +Menu.defaultProps = { + dataTest: 'dhis2-uicore-menu', + maxWidth: '380px', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {*} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + * @prop {string} [maxWidth] + */ +Menu.propTypes = { + children: MenuList.propTypes.children, + className: propTypes.string, + dataTest: propTypes.string, + maxWidth: propTypes.string, +} + +export { Menu } diff --git a/packages/core/src/Menu/Menu.stories.e2e.js b/packages/core/src/Menu/Menu.stories.e2e.js new file mode 100644 index 0000000000..7fae2a4c93 --- /dev/null +++ b/packages/core/src/Menu/Menu.stories.e2e.js @@ -0,0 +1,5 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' +import { Menu } from './Menu.js' + +storiesOf('Menu', module).add('With children', () => I am a child) diff --git a/packages/core/src/Menu/Menu.stories.js b/packages/core/src/Menu/Menu.stories.js new file mode 100644 index 0000000000..f5a8548a5a --- /dev/null +++ b/packages/core/src/Menu/Menu.stories.js @@ -0,0 +1,181 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' + +import { MenuItem, Divider, Switch } from '../index.js' +import { Menu } from './Menu.js' + +const Wrapper = fn =>
{fn()}
+ +storiesOf('Component/Core/Menu', module) + .addDecorator(Wrapper) + + .add('Default', () => ( + + { + alert(`${evt.target.name}: ${evt.target.value}`) + }} + /> + } + /> + + { + alert(`this is ${val}`) + }} + /> + + + + + + + { + alert(`this is ${val}`) + }} + /> + + + + + + + + + { + alert(`this is ${val}`) + }} + /> + { + alert(`this is ${val}`) + }} + /> + { + alert(`this is ${val}`) + }} + /> + { + alert(`this is ${val}`) + }} + /> + + )) + + .add('Short item labels', () => ( + + + + + + + )) + + .add('Dense', () => ( + + { + alert('would need state, but would set to: ' + v) + }} + checked={false} + /> + } + /> + + { + alert(`this is ${val}`) + }} + /> + + + + + + + { + alert(`this is ${val}`) + }} + /> + + + + + + + + + { + alert(`this is ${val}`) + }} + /> + { + alert(`this is ${val}`) + }} + /> + { + alert(`this is ${val}`) + }} + /> + { + alert(`this is ${val}`) + }} + /> + + )) diff --git a/packages/core/src/MenuItem/MenuItem.js b/packages/core/src/MenuItem/MenuItem.js new file mode 100644 index 0000000000..dff6b1fdea --- /dev/null +++ b/packages/core/src/MenuItem/MenuItem.js @@ -0,0 +1,151 @@ +import React from 'react' +import cx from 'classnames' +import css from 'styled-jsx/css' + +import propTypes from '@dhis2/prop-types' +import { ChevronRight } from '@dhis2/ui-icons' + +import styles from './MenuItem.styles.js' + +const subChevron = css.resolve` + svg { + margin: 0 -14px 0 auto; + width: 18px; + height: 18px; + pointer-events: none; + user-select: none; + } +` + +const subMenu = css.resolve` + div { + display: none; + position: absolute; + top: 0; + left: 100%; + white-space: nowrap; + } + + li:hover > :global(span) > :global(div) { + display: block; + } +` + +const SubMenu = ({ children, className }) => ( +
{children}
+) + +SubMenu.propTypes = { + children: propTypes.element, + className: propTypes.string, +} + +const createOnClickHandler = (onClick, value) => evt => { + if (onClick) { + evt.preventDefault() + evt.stopPropagation() + onClick({ value }, evt) + } +} + +/** + * @module + * @param {MenuItem.PropTypes} + * @returns {React.Component} + * + * @example import { MenuItem } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/menu.md|Design system} + * @see Live demo: {@link /demo/?path=/story/menu--default|Storybook} + */ +const MenuItem = ({ + href, + value, + label, + icon, + children, + active, + destructive, + disabled, + dense, + onClick, + className, + target, + dataTest, +}) => { + const hasMenu = !!children + const isClickable = href || onClick + const LinkElement = isClickable ? 'a' : 'span' + const linkElementProps = {} + + if (isClickable) { + linkElementProps.href = href + linkElementProps.onClick = createOnClickHandler(onClick, value) + } + + return ( +
  • + + {icon} +
    {label}
    + + {hasMenu && } + {subChevron.styles} + + {hasMenu && ( + {children} + )} + {subMenu.styles} +
    + + +
  • + ) +} + +MenuItem.defaultProps = { + dataTest: 'dhis2-uicore-menuitem', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string|Node} [label] + * @prop {string} [value] + * @prop {string} [href] + * @prop {function} [onClick] - Click handler called with `value` as the sole argument + * @prop {string} [className] + * @prop {Element} [children] + * @prop {Element} [icon] + * @prop {boolean} [dense] + * @prop {boolean} [active] + * @prop {boolean} [destructive] + * @prop {boolean} [disabled] + * @prop {string} [dataTest] + */ +MenuItem.propTypes = { + active: propTypes.bool, + children: propTypes.element, + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + destructive: propTypes.bool, + disabled: propTypes.bool, + href: propTypes.string, + icon: propTypes.element, + label: propTypes.oneOfType([propTypes.string, propTypes.node]), + target: propTypes.string, + value: propTypes.string, + onClick: propTypes.func, +} + +export { MenuItem } diff --git a/packages/core/src/MenuItem/MenuItem.stories.e2e.js b/packages/core/src/MenuItem/MenuItem.stories.e2e.js new file mode 100644 index 0000000000..d7ad349309 --- /dev/null +++ b/packages/core/src/MenuItem/MenuItem.stories.e2e.js @@ -0,0 +1,18 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' +import { MenuItem } from './MenuItem.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() + +storiesOf('MenuItem', module) + .add('With onClick and value', () => ( + + )) + .add('With href', () => ) + .add('With target', () => ( + + )) + .add('With icon', () => ( + Icon} /> + )) + .add('With label', () => ) diff --git a/packages/core/src/MenuItem/MenuItem.styles.js b/packages/core/src/MenuItem/MenuItem.styles.js new file mode 100644 index 0000000000..9b6e94997c --- /dev/null +++ b/packages/core/src/MenuItem/MenuItem.styles.js @@ -0,0 +1,91 @@ +import css from 'styled-jsx/css' +import { colors, spacers } from '@dhis2/ui-constants' + +export default css` + li { + position: relative; + padding: 0; + cursor: pointer; + list-style: none; + } + + li div.label:not(:first-child) { + margin-left: ${spacers.dp8}; + } + + li:hover { + background-color: ${colors.grey200}; + } + + li:active, + li.active { + background-color: ${colors.grey400}; + } + + .dense .link { + padding: ${spacers.dp8} ${spacers.dp12}; + } + + .dense .label { + font-size: 14px; + line-height: 16px; + } + + .dense .icon { + font-size: 20px; + } + + .disabled { + cursor: not-allowed; + pointer-events: none; + user-select: none; + } + + .disabled .icon, + .disabled .label { + color: ${colors.grey500}; + } + + .destructive .label { + color: ${colors.red700}; + } + + .destructive .icon { + color: ${colors.red600}; + } + + li.destructive:hover { + background-color: ${colors.red050}; + } + + li.destructive:active { + background-color: ${colors.red100}; + } + + .link { + display: block; + height: 100%; + padding: 15px ${spacers.dp24}; + text-decoration: none; + display: flex; + flex-direction: row; + align-items: center; + } + + .label { + color: ${colors.grey900}; + font-size: 15px; + line-height: 18px; + user-select: none; + } + + .icon { + box-sizing: border-box; + + margin-right: ${spacers.dp16}; + color: #404040; + font-size: 24px; + pointer-events: none; + user-select: none; + } +` diff --git a/packages/core/src/MenuList/MenuList.js b/packages/core/src/MenuList/MenuList.js new file mode 100644 index 0000000000..6fc6218df7 --- /dev/null +++ b/packages/core/src/MenuList/MenuList.js @@ -0,0 +1,50 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @module + * @param {MenuList.PropTypes} + * @returns {React.Component} + * + * @example import { MenuList } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/menu.md|Design system} + * @see Live demo: {@link /demo/?path=/story/menulist--default|Storybook} + */ +const MenuList = ({ children, className, dataTest }) => ( +
      + {children} + + +
    +) + +MenuList.defaultProps = { + dataTest: 'dhis2-uicore-menulist', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +MenuList.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +export { MenuList } diff --git a/packages/core/src/MenuList/MenuList.stories.e2e.js b/packages/core/src/MenuList/MenuList.stories.e2e.js new file mode 100644 index 0000000000..1396ebb18d --- /dev/null +++ b/packages/core/src/MenuList/MenuList.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { MenuList } from './MenuList.js' + +storiesOf('MenuList', module).add('With children', () => ( + I am a child +)) diff --git a/packages/core/src/MenuList/MenuList.stories.js b/packages/core/src/MenuList/MenuList.stories.js new file mode 100644 index 0000000000..21f26d6c40 --- /dev/null +++ b/packages/core/src/MenuList/MenuList.stories.js @@ -0,0 +1,138 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' + +import { MenuItem, Divider, Switch } from '../index.js' +import { MenuList } from './MenuList.js' + +const Wrapper = fn =>
    {fn()}
    + +export const FolderOpen = () => ( + + + + + + + + + + + +) + +storiesOf('Component/Core/MenuList', module) + .addDecorator(Wrapper) + + .add('Default', () => ( + + { + alert(`${evt.target.name}: ${evt.target.value}`) + }} + /> + } + /> + + } + label="Three" + value="three" + onClick={val => { + alert(`this is ${val}`) + }} + /> + + + } + label="Two" + value="two" + onClick={val => { + alert(`this is ${val}`) + }} + /> + } + label="Three" + value="three" + onClick={val => { + alert(`this is ${val}`) + }} + /> + + )) + + .add('Dense', () => ( + + } + dense + label={ + { + alert('would need state, but would set to: ' + v) + }} + checked={false} + /> + } + /> + + } + dense + label="Three" + value="three" + onClick={val => { + alert(`this is ${val}`) + }} + /> + + + } + dense + label="Two" + value="two" + onClick={val => { + alert(`this is ${val}`) + }} + /> + } + dense + label="Three" + value="three" + onClick={val => { + alert(`this is ${val}`) + }} + /> + + )) diff --git a/packages/core/src/Modal/Modal.js b/packages/core/src/Modal/Modal.js new file mode 100644 index 0000000000..ec45f0552e --- /dev/null +++ b/packages/core/src/Modal/Modal.js @@ -0,0 +1,89 @@ +import { createPortal } from 'react-dom' +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { ScreenCover } from '../ScreenCover/ScreenCover.js' +import { sharedPropTypes } from '@dhis2/ui-constants' + +import { ModalCard } from './ModalCard.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Modal.PropTypes} props + * @returns {React.Component} + * + * @desc Modal provides a UI to prompt the user to respond to a question + * or a note to the user. + * + * Use Model with the following Components: + * ModelTitle (optional) + * ModelContent (required) + * ModelActions (optional) + * @module + * @param {Modal.PropTypes} props + * @returns {React.Component} + * + * @example import { Modal } from @dhis2/ui-core + * @example + * + * Hello + * Some content here + * + * + * + * + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/modal.md|Design system} + * @see Live demo: {@link /demo/?path=/story/modal--small-title-content-action|Storybook} + */ +export const Modal = ({ + children, + onClose, + small, + large, + className, + position, + dataTest, +}) => + createPortal( + + + , + document.body + ) + +Modal.defaultProps = { + dataTest: 'dhis2-uicore-modal', + position: 'top', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {Node} [children] + * @prop {string} className + * @prop {Function} onClose + * @prop {bool} small + * @prop {bool} large + * @prop {string} [dataTest] + */ +Modal.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + large: sharedPropTypes.sizePropType, + position: sharedPropTypes.insideAlignmentPropType, + small: sharedPropTypes.sizePropType, + // Callback used when clicking on the screen cover + onClose: propTypes.func, +} diff --git a/packages/core/src/Modal/Modal.stories.e2e.js b/packages/core/src/Modal/Modal.stories.e2e.js new file mode 100644 index 0000000000..e46db4ed3f --- /dev/null +++ b/packages/core/src/Modal/Modal.stories.e2e.js @@ -0,0 +1,28 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { + Button, + ButtonStrip, + ModalTitle, + ModalActions, + ModalContent, +} from '../index.js' + +import { Modal } from './Modal.js' + +window.onClose = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Modal', module) + .add('With onClose', () => ( + + Title + Content + + + + + + + + )) + .add('With children', () => I am a child) diff --git a/packages/core/src/Modal/Modal.stories.js b/packages/core/src/Modal/Modal.stories.js new file mode 100644 index 0000000000..e0ac368bbb --- /dev/null +++ b/packages/core/src/Modal/Modal.stories.js @@ -0,0 +1,684 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' + +import { + Button, + ButtonStrip, + ModalTitle, + ModalActions, + ModalContent, + SingleSelect, + SingleSelectOption, +} from '../index.js' + +import { Modal } from './Modal.js' + +const say = something => () => alert(something) + +window.onClose = (payload, event) => { + console.log('onClose payload', payload) + console.log('onClose event', event) +} + +const onClose = (...args) => window.onClose(...args) + +storiesOf('Component/Core/Modal', module) + .add('Default: Content', () => ( + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores + et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus + est Lorem ipsum dolor sit amet. + + + )) + .add('Alignment: Middle', () => ( + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores + et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus + est Lorem ipsum dolor sit amet. + + + )) + .add('Alignment: Bottom', () => ( + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores + et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus + est Lorem ipsum dolor sit amet. + + + )) + .add('Small: Title, Content, Action', () => ( + + + This is a small modal with title, content and primary action + + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores + et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus + est Lorem ipsum dolor sit amet. + + + + + + + + + + + )) + .add('Medium: Title, Content, Action', () => ( + + + This is a medium modal with title, content and primary action + + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores + et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus + est Lorem ipsum dolor sit amet. + + + + + + + + + + + )) + .add('Large: Title, Content, Primary', () => ( + + + This is a large modal with title, content and primary action + + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores + et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus + est Lorem ipsum dolor sit amet. + + + + + + + + + + + )) + .add('Small: Content & Primary', () => ( + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores + et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus + est Lorem ipsum dolor sit amet. + + + + + + + + + + + )) + .add('Small: Destructive Primary', () => ( + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores + et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus + est Lorem ipsum dolor sit amet. + + + + + + + + + + + )) + .add('Small: Clickable screen cover', () => ( + + This is a modal with clickable screen cover + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. + + + + + + + + + + + )) + .add('Top: scrollable', () => ( + + This is a modal with scrollable content + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea + rebum. Stet clita kasd gubergren, no sea takimata sanctus est + Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea + rebum. Stet clita kasd gubergren, no sea takimata sanctus est + Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. Lorem ipsum dolor sit amet, consetetur sadipscing + elitr, sed diam nonumy eirmod tempor invidunt ut labore et + dolore magna aliquyam erat, sed diam voluptua. At vero eos et + accusam et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. Lorem ipsum dolor sit amet, consetetur + sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut + labore et dolore magna aliquyam erat, sed diam voluptua. At vero + eos et accusam et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. Lorem ipsum dolor sit amet, consetetur + sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut + labore et dolore magna aliquyam erat, sed diam voluptua. At vero + eos et accusam et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. + + + + + + + + + + + )) + .add('Middle: scrollable', () => ( + + This is a modal with scrollable content + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea + rebum. Stet clita kasd gubergren, no sea takimata sanctus est + Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea + rebum. Stet clita kasd gubergren, no sea takimata sanctus est + Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. Lorem ipsum dolor sit amet, consetetur sadipscing + elitr, sed diam nonumy eirmod tempor invidunt ut labore et + dolore magna aliquyam erat, sed diam voluptua. At vero eos et + accusam et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. Lorem ipsum dolor sit amet, consetetur + sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut + labore et dolore magna aliquyam erat, sed diam voluptua. At vero + eos et accusam et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. Lorem ipsum dolor sit amet, consetetur + sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut + labore et dolore magna aliquyam erat, sed diam voluptua. At vero + eos et accusam et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. + + + + + + + + + + + )) + .add('Bottom: scrollable', () => ( + + This is a modal with scrollable content + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea + rebum. Stet clita kasd gubergren, no sea takimata sanctus est + Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea + rebum. Stet clita kasd gubergren, no sea takimata sanctus est + Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. Lorem ipsum dolor sit amet, consetetur sadipscing + elitr, sed diam nonumy eirmod tempor invidunt ut labore et + dolore magna aliquyam erat, sed diam voluptua. At vero eos et + accusam et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. Lorem ipsum dolor sit amet, consetetur + sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut + labore et dolore magna aliquyam erat, sed diam voluptua. At vero + eos et accusam et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. Lorem ipsum dolor sit amet, consetetur + sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut + labore et dolore magna aliquyam erat, sed diam voluptua. At vero + eos et accusam et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. + + + + + + + + + + + )) + .add('Small: Long title', () => ( + + + This headline should break into multiple lines because it's + way too long for one! + + + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed + diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. + + + + + + + + + + + )) + .add('Large: with Select component', () => ( + + Select opens on top of the Modal + + + + + + + + + + + + + + + + + + + + + + + + + )) + .add('Large: modal with more nested modals', () => ( + + Select opens on top of the Modal - Level 1 + + + + + + + + + + + + + + + + + Select opens on top of the Modal - Level 2 + + + + + + + + + + + + + + + + + + Select opens on top of the Modal - Level 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )) diff --git a/packages/core/src/Modal/ModalCard.js b/packages/core/src/Modal/ModalCard.js new file mode 100644 index 0000000000..6bc901e023 --- /dev/null +++ b/packages/core/src/Modal/ModalCard.js @@ -0,0 +1,71 @@ +import React from 'react' +import cx from 'classnames' +import { resolve } from 'styled-jsx/css' + +import propTypes from '@dhis2/prop-types' +import { spacersNum, sharedPropTypes } from '@dhis2/ui-constants' + +import { Card } from '../Card/Card.js' + +const cardBoxStyle = resolve` + .modal-card { + display: flex; + flex-direction: column; + height: auto; + left: 50%; + max-height: calc(100vh - ${2 * spacersNum.dp64}px); + max-width: calc(100vw - ${2 * spacersNum.dp64}px); + position: absolute; + width: 600px; + } + + .top { + top: 0; + transform: translate(-50%, 0); + } + + .middle { + transform: translate(-50%, -50%); + } + + .bottom { + bottom: 0; + transform: translate(-50%, 0); + } + + .small { width: 400px; } + .large { width: 800px; } +` + +/** + * @module + * @private + * + * @param {ModalCard.PropTypes} props + * @returns {React.Component} + */ +export const ModalCard = ({ children, small, large, position }) => ( + + {children} + {cardBoxStyle.styles} + +) + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Element} children - Can be Modal.Title; Must be Modal.Content and Modal.Actions + * @prop {boolean} [small] - `small` and `large` are mutually exclusive + * @prop {boolean} [large] + */ +ModalCard.propTypes = { + children: propTypes.node.isRequired, + position: sharedPropTypes.insideAlignmentPropType.isRequired, + large: sharedPropTypes.sizePropType, + small: sharedPropTypes.sizePropType, +} diff --git a/packages/core/src/ModalActions/ModalActions.js b/packages/core/src/ModalActions/ModalActions.js new file mode 100644 index 0000000000..1d9ee1bc18 --- /dev/null +++ b/packages/core/src/ModalActions/ModalActions.js @@ -0,0 +1,40 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { spacers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * + * @param {ModalActions.PropTypes} props + * @returns {React.Component} + */ +export const ModalActions = ({ children, dataTest }) => ( +
    + {children} + + +
    +) + +ModalActions.defaultProps = { + dataTest: 'dhis2-uicore-modalactions', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Object} [children] - Accepts one or more `Element`s + * @prop {string} [dataTest] + */ +ModalActions.propTypes = { + children: propTypes.node, + dataTest: propTypes.string, +} diff --git a/packages/core/src/ModalActions/ModalActions.stories.e2e.js b/packages/core/src/ModalActions/ModalActions.stories.e2e.js new file mode 100644 index 0000000000..f17720a00c --- /dev/null +++ b/packages/core/src/ModalActions/ModalActions.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { ModalActions } from './ModalActions.js' + +storiesOf('ModalActions', module).add('With children', () => ( + I am a child +)) diff --git a/packages/core/src/ModalContent/ModalContent.js b/packages/core/src/ModalContent/ModalContent.js new file mode 100644 index 0000000000..46f68204e0 --- /dev/null +++ b/packages/core/src/ModalContent/ModalContent.js @@ -0,0 +1,43 @@ +import React from 'react' + +import propTypes from '@dhis2/prop-types' +import { spacers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * + * @param {ModalContent.PropTypes} props + * @returns {React.Component} + */ +export const ModalContent = ({ children, className, dataTest }) => ( +
    + {children} + + +
    +) + +ModalContent.defaultProps = { + dataTest: 'dhis2-uicore-modalcontent', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +ModalContent.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/ModalContent/ModalContent.stories.e2e.js b/packages/core/src/ModalContent/ModalContent.stories.e2e.js new file mode 100644 index 0000000000..1e024e0171 --- /dev/null +++ b/packages/core/src/ModalContent/ModalContent.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { ModalContent } from './ModalContent.js' + +storiesOf('ModalContent', module).add('With children', () => ( + I am a child +)) diff --git a/packages/core/src/ModalTitle/ModalTitle.js b/packages/core/src/ModalTitle/ModalTitle.js new file mode 100644 index 0000000000..7a6ebadf2b --- /dev/null +++ b/packages/core/src/ModalTitle/ModalTitle.js @@ -0,0 +1,42 @@ +import React from 'react' +import cx from 'classnames' + +import propTypes from '@dhis2/prop-types' +import { spacers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {ModalTitle.PropTypes} props + * @returns {React.Component} + */ +export const ModalTitle = ({ children, dataTest }) => ( +

    + {children} + + +

    +) + +ModalTitle.defaultProps = { + dataTest: 'dhis2-uicore-modaltitle', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [children] + * @prop {string} [dataTest] + */ +ModalTitle.propTypes = { + children: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/ModalTitle/ModalTitle.stories.e2e.js b/packages/core/src/ModalTitle/ModalTitle.stories.e2e.js new file mode 100644 index 0000000000..409f4b3a2f --- /dev/null +++ b/packages/core/src/ModalTitle/ModalTitle.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { ModalTitle } from './ModalTitle.js' + +storiesOf('ModalTitle', module).add('With children', () => ( + I am a child +)) diff --git a/packages/core/src/MultiSelect/FilterableMenu.js b/packages/core/src/MultiSelect/FilterableMenu.js new file mode 100644 index 0000000000..dff8df3445 --- /dev/null +++ b/packages/core/src/MultiSelect/FilterableMenu.js @@ -0,0 +1,44 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { sharedPropTypes } from '@dhis2/ui-constants' +import { FilterableMenu as CommonFilterableMenu } from '../Select/FilterableMenu.js' +import { Menu } from './Menu.js' + +const FilterableMenu = ({ + dataTest, + options, + onChange, + selected, + empty, + handleClose, + handleFocusInput, + placeholder, + noMatchText, +}) => ( + +) + +FilterableMenu.propTypes = { + dataTest: propTypes.string.isRequired, + noMatchText: propTypes.string.isRequired, + empty: propTypes.node, + handleClose: propTypes.func, + handleFocusInput: propTypes.func, + options: propTypes.node, + placeholder: propTypes.string, + selected: sharedPropTypes.multiSelectedPropType, + onChange: propTypes.func, +} + +export { FilterableMenu } diff --git a/packages/core/src/MultiSelect/Input.js b/packages/core/src/MultiSelect/Input.js new file mode 100644 index 0000000000..7b6480a208 --- /dev/null +++ b/packages/core/src/MultiSelect/Input.js @@ -0,0 +1,108 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' +import { colors, sharedPropTypes } from '@dhis2/ui-constants' +import { SelectionList } from './SelectionList.js' +import { InputPlaceholder } from '../Select/InputPlaceholder.js' +import { InputPrefix } from '../Select/InputPrefix.js' +import { InputClearButton } from '../Select/InputClearButton.js' + +const Input = ({ + selected, + onChange, + clearable, + clearText, + placeholder, + dataTest, + prefix, + options, + className, + disabled, + inputMaxHeight, +}) => { + const hasSelection = selected.length > 0 + const onClear = (_, e) => { + const data = { selected: [] } + + e.stopPropagation() + onChange(data, e) + } + + return ( +
    + + {!hasSelection && !prefix && ( + + )} + {hasSelection && ( +
    + {/* the wrapper div above is necessary to enforce wrapping on overflow */} + +
    + )} + {hasSelection && clearable && !disabled && ( +
    + +
    + )} + + + + +
    + ) +} + +Input.defaultProps = { + inputMaxHeight: '100px', +} + +Input.propTypes = { + dataTest: propTypes.string.isRequired, + className: propTypes.string, + clearText: propTypes.requiredIf(props => props.clearable, propTypes.string), + clearable: propTypes.bool, + disabled: propTypes.bool, + inputMaxHeight: propTypes.string, + options: propTypes.node, + placeholder: propTypes.string, + prefix: propTypes.string, + selected: sharedPropTypes.multiSelectedPropType, + onChange: propTypes.func, +} + +export { Input } diff --git a/packages/core/src/MultiSelect/Menu.js b/packages/core/src/MultiSelect/Menu.js new file mode 100644 index 0000000000..efb3dae4f7 --- /dev/null +++ b/packages/core/src/MultiSelect/Menu.js @@ -0,0 +1,99 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { sharedPropTypes } from '@dhis2/ui-constants' +import { Empty } from '../Select/Empty.js' +import { + filterIgnored, + checkIfValidOption, + removeOption, + findOption, +} from '../Select/option-helpers.js' + +const onDisabledClick = (_, e) => { + e.stopPropagation() + e.preventDefault() +} + +const createHandler = ({ isActive, onChange, selected, value, label }) => ( + _, + e +) => { + const clickedOption = { value, label } + e.stopPropagation() + e.preventDefault() + + // If the option is currently selected remove it from the array of selected options + if (isActive) { + const filtered = removeOption(clickedOption, selected) + const data = { selected: filtered } + + return onChange(data, e) + } + + // Otherwise, add it to selected + const data = { + selected: selected.concat([clickedOption]), + } + return onChange(data, e) +} + +const Menu = ({ options, onChange, selected, empty, dataTest }) => { + const renderedOptions = filterIgnored(options) + + if (React.Children.count(renderedOptions) === 0) { + // If it's a string, supply it to our component so it looks better + if (typeof empty === 'string') { + return + } + + // Otherwise just render the supplied markup + return empty + } + + const children = React.Children.map(options, child => { + const isValidOption = checkIfValidOption(child) + + // Return early if the child isn't an option, to prevent attaching handlers etc. + if (!isValidOption) { + return child + } + + const { value, label, disabled: isDisabled } = child.props + + // Active means the option is currently selected + const isActive = !!findOption({ value, label }, selected) + + // Create the appropriate click handler for the option + const onClick = isDisabled + ? onDisabledClick + : createHandler({ + isActive, + onChange, + selected, + value, + label, + }) + + return React.cloneElement(child, { + ...child.props, + onClick, + active: isActive, + }) + }) + + return {children} +} + +Menu.defaultProps = { + empty: '', +} + +Menu.propTypes = { + dataTest: propTypes.string.isRequired, + empty: propTypes.node, + options: propTypes.node, + selected: sharedPropTypes.multiSelectedPropType, + onChange: propTypes.func, +} + +export { Menu } diff --git a/packages/core/src/MultiSelect/MultiSelect.js b/packages/core/src/MultiSelect/MultiSelect.js new file mode 100644 index 0000000000..06100c3fd7 --- /dev/null +++ b/packages/core/src/MultiSelect/MultiSelect.js @@ -0,0 +1,187 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { spacers, sharedPropTypes } from '@dhis2/ui-constants' +import { StatusIcon } from '@dhis2/ui-icons' + +import { Select } from '../Select/Select.js' +import { Loading } from '../Select/Loading.js' + +import { Input } from './Input.js' +import { Menu } from './Menu.js' +import { FilterableMenu } from './FilterableMenu.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * + * @param {MultiSelect.PropTypes} props + * @returns {React.Component} + * + * @example import { MultiSelect } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/select.md|Design system} + */ +const MultiSelect = ({ + className, + selected, + tabIndex, + maxHeight, + inputMaxHeight, + onChange, + onFocus, + onBlur, + loading, + error, + warning, + valid, + disabled, + children, + clearable, + clearText, + filterable, + filterPlaceholder, + placeholder, + prefix, + empty, + loadingText, + noMatchText, + initialFocus, + dense, + dataTest, +}) => { + // If the select is filterable, use a filterable menu + const menu = filterable ? ( + + ) : ( + + ) + + return ( +
    +
    + +
    + + + +
    + ) +} + +MultiSelect.defaultProps = { + selected: [], + dataTest: 'dhis2-uicore-multiselect', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {function} [onChange] + * @prop {Array} [selected] + * @prop {string} [className] + * @prop {string} [tabIndex] + * @prop {Node} [children] + * @prop {boolean} [disabled] + * @prop {boolean} [dense] + * @prop {boolean} [valid] - `valid`, `warning`, `error`, `loading`, are mutually exclusive + * @prop {boolean} [warning] + * @prop {boolean} [error] + * @prop {boolean} [loading] + * @prop {function} [onFocus] + * @prop {function} [onBlur] + * @prop {boolean} [initialFocus] + * @prop {string} [clearText] - Only required if clearable is true + * @prop {boolean} [clearable] + * @prop {Node} [empty] + * @prop {string} [filterPlaceholder] + * @prop {boolean} [filterable] + * @prop {string} [loadingText] + * @prop {string} [maxHeight] + * @prop {string} [inputMaxHeight] + * @prop {string} [noMatchText] - Only required if filterable is true + * @prop {string} [placeholder] + * @prop {string} [prefix] + * @prop {string} [dataTest] + */ +MultiSelect.propTypes = { + children: propTypes.node, + className: propTypes.string, + clearText: propTypes.requiredIf(props => props.clearable, propTypes.string), + clearable: propTypes.bool, + dataTest: propTypes.string, + dense: propTypes.bool, + disabled: propTypes.bool, + empty: propTypes.node, + error: sharedPropTypes.statusPropType, + filterPlaceholder: propTypes.string, + filterable: propTypes.bool, + initialFocus: propTypes.bool, + inputMaxHeight: propTypes.string, + loading: propTypes.bool, + loadingText: propTypes.string, + maxHeight: propTypes.string, + noMatchText: propTypes.requiredIf( + props => props.filterable, + propTypes.string + ), + placeholder: propTypes.string, + prefix: propTypes.string, + selected: sharedPropTypes.multiSelectedPropType, + tabIndex: propTypes.string, + valid: sharedPropTypes.statusPropType, + warning: sharedPropTypes.statusPropType, + onBlur: propTypes.func, + onChange: propTypes.func, + onFocus: propTypes.func, +} + +export { MultiSelect } diff --git a/packages/core/src/MultiSelect/MultiSelect.stories.e2e.js b/packages/core/src/MultiSelect/MultiSelect.stories.e2e.js new file mode 100644 index 0000000000..6a84e0febe --- /dev/null +++ b/packages/core/src/MultiSelect/MultiSelect.stories.e2e.js @@ -0,0 +1,254 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { storiesOf } from '@storybook/react' + +import { MultiSelect } from './MultiSelect.js' +import { MultiSelectOption } from '../index.js' + +window.onChange = window.Cypress && window.Cypress.cy.stub() +window.onFocus = window.Cypress && window.Cypress.cy.stub() +window.onBlur = window.Cypress && window.Cypress.cy.stub() + +const CustomMultiSelectOption = ({ label, onClick }) => ( +
    onClick({}, e)}>{label}
    +) + +CustomMultiSelectOption.propTypes = { + label: propTypes.string, + onClick: propTypes.func, +} + +storiesOf('MultiSelect', module) + .add('With options', () => ( + + + + + + )) + .add('With options and onChange', () => ( + + + + + + )) + .add('With onFocus', () => ( + + + + + + )) + .add('With onBlur', () => ( + + + + + + )) + .add('With custom options and onChange', () => ( + + + + + + )) + .add('With invalid options', () => ( + +
    invalid one
    + +
    invalid two
    + +
    invalid three
    + + {null} + {undefined} + {false} +
    + )) + .add('With invalid filterable options', () => ( + +
    invalid one
    + +
    invalid two
    + +
    invalid three
    + +
    + )) + .add('With initialFocus', () => ( + + )) + .add('Empty', () => ) + .add('Empty with empty text', () => ( + + )) + .add('Empty with empty component', () => ( + Custom empty component} + /> + )) + .add('With options and loading', () => ( + + + + + + )) + .add('With options, loading and loading text', () => ( + + + + + + )) + .add('With more than ten options', () => ( + + + + + + + + + + + + + + + )) + .add('With more than three options and a 100px max-height', () => ( + + + + + + + + + + + + + + + )) + .add('With options, a selection and disabled', () => ( + + + + + + )) + .add('With options and disabled', () => ( + + + + + + )) + .add('With prefix', () => ( + + + + + + )) + .add('With prefix and selection', () => ( + + + + + + )) + .add('With placeholder', () => ( + + + + + + )) + .add('With placeholder and selection', () => ( + + + + + + )) + .add('With disabled option and onChange', () => ( + + + + + + + )) + .add('With options and a selection', () => ( + + + + + + )) + .add('With options, a selection and onChange', () => ( + + + + + + )) + .add('With options and multiple selections', () => ( + + + + + + )) + .add('With clear button, selection and onChange', () => ( + + + + + + )) + .add('With filter field', () => ( + + + + + + )) diff --git a/packages/core/src/MultiSelect/MultiSelect.stories.js b/packages/core/src/MultiSelect/MultiSelect.stories.js new file mode 100644 index 0000000000..8d30f89c9f --- /dev/null +++ b/packages/core/src/MultiSelect/MultiSelect.stories.js @@ -0,0 +1,272 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' + +import { MultiSelect } from './MultiSelect.js' +import { MultiSelectOption } from '../MultiSelectOption/MultiSelectOption.js' + +const CheckIcon = () => ( + + + +) + +const CustomOption = ({ label, description, icon, active, onClick }) => ( + onClick({}, e)}> + {icon &&
    {icon}
    } +
    +

    {label}

    +

    {description}

    +
    + {active && ( +
    + +
    + )} + + +
    +) + +CustomOption.propTypes = { + // This prop is used by the Select, so still necessary + label: propTypes.string.isRequired, + // eslint-disable-next-line react/no-unused-prop-types + value: propTypes.string.isRequired, + active: propTypes.bool, + description: propTypes.string, + icon: propTypes.element, + onClick: propTypes.func, +} + +const longLabel = + 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Quas quibusdam est debitis odio, earum explicabo error magni. Expedita voluptatem accusantium nostrum minus, voluptate mollitia placeat perspiciatis quibusdam dicta, fugiat dolor quisquam alias, impedit commodi? Inventore, laboriosam, molestias. Non quod odit eum adipisci, tempora rerum perferendis, incidunt consectetur nesciunt. Qui voluptatibus asperiores dicta doloremque est vitae tempora sapiente dignissimos maiores saepe, animi necessitatibus modi laboriosam quae blanditiis voluptatem recusandae. Praesentium ipsum dolores voluptatum repudiandae cumque, tempore tempora, excepturi numquam atque reiciendis suscipit alias? Architecto dignissimos enim blanditiis laborum' + +const defaultProps = { + selected: [{ value: '1', label: 'one' }], + onChange: ({ selected }) => { + alert(`Selected changed to: ${JSON.stringify(selected, null, 2)}`) + }, +} + +const options = [ + , + , + , + , + , + , + , + , + , + , +] + +storiesOf('Component/Core/MultiSelect', module) + .add('Default', () => ( + {options} + )) + .add('Long label', () => ( + + + + + )) + .add('Disabled option', () => ( + + + + + + + + + + + + + )) + .add('Disabled and currently selected option', () => ( + + + + + + + + + + + + + )) + .add('Custom options', () => ( + + + + + + )) + .add('Multiple selected options', () => ( + + {options} + + )) + .add('No placeholder, no selected options', () => ( + + {options} + + )) + .add('Placeholder, no selected options', () => ( + + {options} + + )) + .add('Prefix, no selected options', () => ( + + {options} + + )) + .add('Prefix, with selected options', () => ( + + {options} + + )) + .add('Status: Loading', () => ( + + {options} + + )) + .add('Status: Loading with text', () => ( + + {options} + + )) + .add('Maximum height', () => ( + + {options} + + )) + .add('Clearable', () => ( + + {options} + + )) + .add('Filterable', () => ( + + {options} + + )) + .add('Empty', () => ) + .add('Custom empty', () => ( + There are no options} + /> + )) + .add('Initial focus', () => ( + + {options} + + )) + .add('Dense', () => ( + + {options} + + )) + .add('Disabled', () => ( + + {options} + + )) + .add('Input max height', () => ( + + {options} + + )) diff --git a/packages/core/src/MultiSelect/SelectionList.js b/packages/core/src/MultiSelect/SelectionList.js new file mode 100644 index 0000000000..8ef79d8cb4 --- /dev/null +++ b/packages/core/src/MultiSelect/SelectionList.js @@ -0,0 +1,65 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { sharedPropTypes } from '@dhis2/ui-constants' +import { Chip } from '../Chip/Chip.js' +import { removeOption, findOptionChild } from '../Select/option-helpers.js' + +const createRemoveHandler = ({ selected, onChange, value, label }) => ( + _, + e +) => { + const clickedOption = { value, label } + const filtered = removeOption(clickedOption, selected) + const data = { selected: filtered } + + onChange(data, e) +} + +const SelectionList = ({ selected, onChange, disabled, options }) => ( + + {selected.map(({ value, label }) => { + const selectedOption = findOptionChild({ value, label }, options) + + if (!selectedOption) { + const message = + 'The selected option could not be found as a child of the select. ' + + 'Make sure that the value and label for all options passed to the ' + + '`selected` prop matches an existing option.' + throw new Error(message) + } + + // The chip should be disabled if the option or the select are disabled + const isDisabled = selectedOption.props.disabled || disabled + + // Create an onRemove handler, but only if it's not disabled + const onRemove = isDisabled + ? undefined + : createRemoveHandler({ + selected, + onChange, + value, + label, + }) + + return ( + + {label} + + ) + })} + +) + +SelectionList.propTypes = { + disabled: propTypes.bool, + options: propTypes.node, + selected: sharedPropTypes.multiSelectedPropType, + onChange: propTypes.func, +} + +export { SelectionList } diff --git a/packages/core/src/MultiSelectOption/MultiSelectOption.js b/packages/core/src/MultiSelectOption/MultiSelectOption.js new file mode 100644 index 0000000000..1430a548e9 --- /dev/null +++ b/packages/core/src/MultiSelectOption/MultiSelectOption.js @@ -0,0 +1,91 @@ +import React from 'react' +import cx from 'classnames' +import { resolve } from 'styled-jsx/css' + +import propTypes from '@dhis2/prop-types' +import { colors, spacers } from '@dhis2/ui-constants' + +import { Checkbox } from '../Checkbox/Checkbox.js' + +// Padding has to be set on the label, so that the entire area is clickable +const { styles, className: checkboxClassname } = resolve` + padding: ${spacers.dp8} ${spacers.dp12}; +` + +/** + * @module + * + * @param {MultiSelectOption.PropTypes} props + * @returns {React.Component} + * + * @example import { MultiSelectOption } from '@dhis2/ui-core' + * + */ + +const MultiSelectOption = ({ + label, + active, + disabled, + onClick, + className, + dataTest, + value, +}) => ( +
    + + + {styles} + + +
    +) + +MultiSelectOption.defaultProps = { + dataTest: 'dhis2-uicore-multiselectoption', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} value + * @prop {string} label + * @prop {function} [onChange] + * @prop {string} [className] + * @prop {function} [onClick] + * @prop {boolean} [active] + * @prop {boolean} [disabled] + * @prop {string} [dataTest] + */ +MultiSelectOption.propTypes = { + label: propTypes.string.isRequired, + value: propTypes.string.isRequired, + active: propTypes.bool, + className: propTypes.string, + dataTest: propTypes.string, + disabled: propTypes.bool, + onClick: propTypes.func, +} + +export { MultiSelectOption } diff --git a/packages/core/src/Node/Leaves.js b/packages/core/src/Node/Leaves.js new file mode 100644 index 0000000000..c3607e6f37 --- /dev/null +++ b/packages/core/src/Node/Leaves.js @@ -0,0 +1,27 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +export const Leaves = ({ children, open, dataTest }) => ( +
    + {children} + + +
    +) + +Leaves.propTypes = { + children: propTypes.node, + dataTest: propTypes.string, + open: propTypes.bool, +} diff --git a/packages/core/src/Node/Node.js b/packages/core/src/Node/Node.js new file mode 100644 index 0000000000..170e17ac9e --- /dev/null +++ b/packages/core/src/Node/Node.js @@ -0,0 +1,90 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { Toggle } from './Toggle.js' +import { Spacer } from './Spacer.js' +import { Leaves } from './Leaves.js' + +/** + * @module + * + * @param {Node.PropTypes} props + * @returns {React.Component} + * + * @example import { Node } from '@dhis2/ui-core' + * + * @see Live demo: {@link /demo/?path=/story/node--multiple-roots|Storybook} + */ +export const Node = ({ + open, + className, + component: label, + children, + icon, + onOpen, + onClose, + dataTest, +}) => { + const hasLeaves = !!React.Children.toArray(children).filter(i => i).length + const showArrow = !icon && hasLeaves + const showSpacer = !icon && !hasLeaves + + return ( +
    + {icon &&
    {icon}
    } + + {showArrow && ( + + )} + + {showSpacer && } + +
    +
    {label}
    + + + {children} + +
    + + +
    + ) +} + +Node.defaultProps = { + dataTest: 'dhis2-uicore-node', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Element} [component] + * @prop {className} [string] + * @prop {Node} [children] + * @prop {Node} [icon] + * @prop {boolean} [open] + * @prop {function} [onOpen] + * @prop {funtion} [onClose] + * @prop {string} [dataTest] + */ +Node.propTypes = { + children: propTypes.node, + className: propTypes.string, + component: propTypes.element, + dataTest: propTypes.string, + icon: propTypes.node, + open: propTypes.bool, + onClose: propTypes.func, + onOpen: propTypes.func, +} diff --git a/packages/core/src/Node/Node.stories.e2e.js b/packages/core/src/Node/Node.stories.e2e.js new file mode 100644 index 0000000000..0408d7bbf5 --- /dev/null +++ b/packages/core/src/Node/Node.stories.e2e.js @@ -0,0 +1,34 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Node } from './Node.js' + +window.onClose = window.Cypress && window.Cypress.cy.stub() +window.onOpen = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Node', module) + .add('Open with onClose', () => ( + Component}> + Children + + )) + .add('Closed with onOpen', () => ( + Component}> + Children + + )) + .add('Closed with children', () => ( + Component}>I am a child + )) + .add('Open with children', () => ( + Component}> + I am a child + + )) + .add('With component', () => ( + I am a component}>Children + )) + .add('With icon', () => ( + Component} icon={
    Icon
    }> + Children +
    + )) diff --git a/packages/core/src/Node/Node.stories.js b/packages/core/src/Node/Node.stories.js new file mode 100644 index 0000000000..2c6ae39fe5 --- /dev/null +++ b/packages/core/src/Node/Node.stories.js @@ -0,0 +1,405 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' +import { resolve } from 'styled-jsx/css' + +import { Node } from './Node.js' +import { Checkbox, CircularLoader } from '../index.js' + +const say = something => () => alert(something) + +window.onOpen = (payload, event) => { + console.log('onOpen payload', payload) + console.log('onOpen event', event) +} + +window.onClose = (payload, event) => { + console.log('onClose payload', payload) + console.log('onClose event', event) +} + +const onOpen = (...args) => window.onOpen(...args) +const onClose = (...args) => window.onClose(...args) + +const loadingSpinnerStyles = resolve` + .small { + margin: 3px 0; + width: 24px; + height: 18px; + } +` + +const LoadingSpinner = () => ( + + + + +) + +storiesOf('Component/Core/Node', module) + .add('Custom icon', () => ( + } + open={false} + onOpen={onOpen} + onClose={onClose} + component={ + + } + /> + )) + + .add('Multiple roots', () => ( +
    + + } + > + Placeholder content + + + + } + > + Placeholder content + +
    + )) + + .add('2 Levels open', () => ( + + } + > + + } + > + + } + /> + + } + /> + + } + /> + + + } + > + + } + /> + + } + /> + + } + /> + + + } + /> + + } + /> + + } + /> + + } + > + {false && 'Foo'} + + + )) + + .add('Text leaves', () => ( +
    + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr + + } + > + Sed diam nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam et + justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores + et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus + est Lorem ipsum dolor sit amet. +
    + + Lorem ipsum dolor sit amet, consetetur + sadipscing elitr + + } + > + Sed diam nonumy eirmod tempor invidunt ut labore et + dolore magna aliquyam erat, sed diam voluptua. At vero + eos et accusam et justo duo dolores et ea rebum. Stet + clita kasd gubergren, no sea takimata sanctus est Lorem + ipsum dolor sit amet. Lorem ipsum dolor sit amet, + consetetur sadipscing elitr, sed diam nonumy eirmod + tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo + dolores et ea rebum. Stet clita kasd gubergren, no sea + takimata sanctus est Lorem ipsum dolor sit amet. + +
    +
    + + Lorem ipsum dolor sit amet, consetetur + sadipscing elitr + + } + > + Dummy content + +
    +
    + + Lorem ipsum dolor sit amet, consetetur + sadipscing elitr + + } + > + Dummy content + +
    +
    + + Lorem ipsum dolor sit amet, consetetur + sadipscing elitr + + } + > + Dummy content + +
    +
    + + Lorem ipsum dolor sit amet, consetetur + sadipscing elitr + + } + > + Dummy content + +
    +
    + + +
    + )) diff --git a/packages/core/src/Node/Spacer.js b/packages/core/src/Node/Spacer.js new file mode 100644 index 0000000000..b6d75cf252 --- /dev/null +++ b/packages/core/src/Node/Spacer.js @@ -0,0 +1,11 @@ +import React from 'react' + +export const Spacer = () => ( +
    + +
    +) diff --git a/packages/core/src/Node/Toggle.js b/packages/core/src/Node/Toggle.js new file mode 100644 index 0000000000..79f58d9798 --- /dev/null +++ b/packages/core/src/Node/Toggle.js @@ -0,0 +1,76 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { colors } from '@dhis2/ui-constants' + +const ArrowDown = () => ( + + + + +) + +export const Toggle = ({ open, onOpen, onClose, dataTest }) => { + const onClick = open ? onClose : onOpen + + return ( +
    onClick && onClick({ open: !open }, event)} + > + + + + + +
    + ) +} + +Toggle.propTypes = { + dataTest: propTypes.string.isRequired, + open: propTypes.bool, + onClose: propTypes.func, + onOpen: propTypes.func, +} diff --git a/packages/core/src/Popover/Arrow.js b/packages/core/src/Popover/Arrow.js new file mode 100644 index 0000000000..9b80795b9b --- /dev/null +++ b/packages/core/src/Popover/Arrow.js @@ -0,0 +1,79 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors } from '@dhis2/ui-constants' + +const ARROW_SIZE = 8 + +/** + * Note: the `data-popper-placement` attribute is automatically updated + * by popper.js in case of flipping etc. So be aware that the value of + * the `placement` prop that was initially passed to the Popper component + * and the value of `data-popper-placement` do not always match + */ + +const Arrow = ({ className }) => ( +
    + +
    +) + +Arrow.propTypes = { + className: propTypes.string, +} + +export { Arrow, ARROW_SIZE } diff --git a/packages/core/src/Popover/Popover.js b/packages/core/src/Popover/Popover.js new file mode 100644 index 0000000000..4f695cd277 --- /dev/null +++ b/packages/core/src/Popover/Popover.js @@ -0,0 +1,93 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import propTypes from 'prop-types' + +import { colors, elevations, sharedPropTypes } from '@dhis2/ui-constants' + +import { Popper } from '../Popper/Popper.js' +import { Backdrop } from '../Backdrop/Backdrop.js' + +import { Arrow } from './Arrow.js' +import { arrow, offset, hideArrowWhenDisplaced } from './modifiers.js' + +const arrowModifiers = [arrow, offset, hideArrowWhenDisplaced] +/** + * @module + * @param {Popover.PropTypes} props + * @returns {React.Component} + * + * @example import { Popover } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/popover.md|Design system} + * @see Live demo: {@link /demo/?path=/story/popover--default|Storybook} + */ + +const Popover = ({ + children, + reference, + arrow, + className, + dataTest, + elevation, + maxWidth, + placement, + onBackdropClick, +}) => + createPortal( + + +
    + {children} + {arrow && } + +
    +
    +
    , + document.body + ) + +Popover.defaultProps = { + arrow: true, + dataTest: 'dhis2-uicore-popover', + elevation: elevations.e200, + maxWidth: 360, + placement: 'top', +} +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {React.Ref} reference A React ref that refers to the element the Popover should position against + * @prop {Node} children + * @prop {boolean} [arrow=true] Show or hide the arrow + * @prop {string} [className] + * @prop {string} [dataTest=dhis2-uicore-popover] + * @prop {number} [maxWidth=360] + * @prop {('auto'|'auto-start'|'auto-end'|'top'|'top-start'|'top-end'|'bottom'|'bottom-start'|'bottom-end'|'right'|'right-start'|'right-end'|'left'|'left-start'|'left-end')} [placement=top] + * @prop {function} [onBackdropClick] + */ +Popover.propTypes = { + children: propTypes.node.isRequired, + reference: sharedPropTypes.elementRefPropType.isRequired, + arrow: propTypes.bool, + className: propTypes.string, + dataTest: propTypes.string, + maxWidth: propTypes.number, + placement: sharedPropTypes.referencePlacementPropType, + onBackdropClick: propTypes.func, +} + +export { Popover } diff --git a/packages/core/src/Popover/Popover.stories.e2e.js b/packages/core/src/Popover/Popover.stories.e2e.js new file mode 100644 index 0000000000..9fda5a2dac --- /dev/null +++ b/packages/core/src/Popover/Popover.stories.e2e.js @@ -0,0 +1,68 @@ +import React, { Component, createRef } from 'react' +import propTypes from '@dhis2/prop-types' + +import { Popover } from './Popover.js' + +const boxStyle = { + display: 'flex', + justifyContent: 'center', + width: 400, + backgroundColor: 'aliceblue', +} + +const referenceElementStyle = { + width: 100, + height: 50, + backgroundColor: 'cadetblue', + textAlign: 'center', + padding: 6, +} + +class PopperInBoxWithCenteredReferenceElement extends Component { + ref = createRef() + + render() { + const { paddingTop, ...popoverProps } = this.props + return ( +
    +
    + Reference element +
    + +
    + I am in a box with width: 360px and height: 200px +
    +
    +
    + ) + } +} +PopperInBoxWithCenteredReferenceElement.defaultProps = { + paddingTop: 220, +} +PopperInBoxWithCenteredReferenceElement.propTypes = { + paddingTop: propTypes.number, +} + +export default { title: 'Popover', component: Popover } + +export const Default = () => +export const Flipped = () => ( + // default viewport-height for flipped popover + // viePort height 400px for diplaced with arrow + +) +export const HiddenArrow = () => ( + // viewPort height 325px + +) +export const NoArrow = () => ( + +) diff --git a/packages/core/src/Popover/Popover.stories.js b/packages/core/src/Popover/Popover.stories.js new file mode 100644 index 0000000000..cc3d946fde --- /dev/null +++ b/packages/core/src/Popover/Popover.stories.js @@ -0,0 +1,112 @@ +import React, { useRef } from 'react' + +import { elevations } from '@dhis2/ui-constants' + +import { Popover } from './Popover.js' + +export default { title: 'Component/Widget/Popover', component: Popover } + +const boxStyle = { + display: 'flex', + justifyContent: 'center', + width: 400, + paddingTop: 280, + backgroundColor: 'aliceblue', +} + +const referenceElementStyle = { + width: 100, + height: 50, + backgroundColor: 'cadetblue', + textAlign: 'center', + padding: 6, +} + +export const Default = () => { + const ref = useRef(null) + + return ( +
    +
    + Reference element +
    + +
    + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed + do eiusmod tempor incididunt ut labore et dolore magna + aliqua. Consectetur purus ut faucibus pulvinar elementum. + Dignissim diam quis enim lobortis scelerisque fermentum dui + faucibus. Rhoncus aenean vel elit scelerisque mauris + pellentesque. Non sodales neque sodales ut etiam sit amet. + Volutpat sed cras ornare arcu dui. Quis imperdiet massa + tincidunt nunc pulvinar sapien et ligula. Convallis posuere + morbi leo urna molestie at. Mauris cursus mattis molestie a + iaculis at. +
    +
    +
    + ) +} + +export const NoArrow = () => { + const ref = useRef(null) + + return ( +
    +
    + Reference element +
    + +
    + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed + do eiusmod tempor incididunt ut labore et dolore magna + aliqua. Consectetur purus ut faucibus pulvinar elementum. + Dignissim diam quis enim lobortis scelerisque fermentum dui + faucibus. Rhoncus aenean vel elit scelerisque mauris + pellentesque. Non sodales neque sodales ut etiam sit amet. + Volutpat sed cras ornare arcu dui. Quis imperdiet massa + tincidunt nunc pulvinar sapien et ligula. Convallis posuere + morbi leo urna molestie at. Mauris cursus mattis molestie a + iaculis at. +
    +
    +
    + ) +} + +export const Customization = () => { + const ref = useRef(null) + + return ( +
    +
    + Reference element +
    + { + console.log('backdrop was closed....') + }} + > +
    + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed + do eiusmod tempor incididunt ut labore et dolore magna + aliqua. Consectetur purus ut faucibus pulvinar elementum. + Dignissim diam quis enim lobortis scelerisque fermentum dui + faucibus. Rhoncus aenean vel elit scelerisque mauris + pellentesque. Non sodales neque sodales ut etiam sit amet. + Volutpat sed cras ornare arcu dui. Quis imperdiet massa + tincidunt nunc pulvinar sapien et ligula. Convallis posuere + morbi leo urna molestie at. Mauris cursus mattis molestie a + iaculis at. +
    +
    +
    + ) +} diff --git a/packages/core/src/Popover/modifiers.js b/packages/core/src/Popover/modifiers.js new file mode 100644 index 0000000000..20bf97d568 --- /dev/null +++ b/packages/core/src/Popover/modifiers.js @@ -0,0 +1,45 @@ +import { ARROW_SIZE } from './Arrow.js' + +const BORDER_RADIUS = 4 + +const computeArrowPadding = () => { + // pythagoras + const diagonal = Math.sqrt(2 * Math.pow(ARROW_SIZE, 2)) + const overflowInPx = (diagonal - ARROW_SIZE) / 2 + const padding = BORDER_RADIUS + overflowInPx + + return Math.ceil(padding) +} + +export const offset = { + name: 'offset', + options: { + offset: [0, ARROW_SIZE], + }, +} + +export const arrow = { + name: 'arrow', + options: { + padding: computeArrowPadding(), + }, +} + +export const hideArrowWhenDisplaced = { + name: 'hideArrowWhenDisplaced', + enabled: true, + phase: 'main', + fn: ({ state }) => { + const halfArrow = ARROW_SIZE / 2 + const displacement = state.modifiersData.preventOverflow + const referenceRect = state.rects.reference + const shouldHideArrow = + Math.abs(displacement.x) >= referenceRect.width + halfArrow || + Math.abs(displacement.y) >= referenceRect.height + halfArrow + + state.attributes.popper['data-popper-arrow-displaced'] = shouldHideArrow + + return state + }, + requires: ['preventOverflow'], +} diff --git a/packages/core/src/Popper/Popper.js b/packages/core/src/Popper/Popper.js new file mode 100644 index 0000000000..fbe852246e --- /dev/null +++ b/packages/core/src/Popper/Popper.js @@ -0,0 +1,113 @@ +import React, { Component, createRef } from 'react' +import propTypes from '@dhis2/prop-types' +import { createPopper } from '@popperjs/core' + +import { sharedPropTypes } from '@dhis2/ui-constants' + +import * as baseModifiers from './modifiers.js' + +/** + * @module + * @param {Popper.PropTypes} props + * @returns {React.Component} + * @description A React wrapper around popper.js. + * + * @example import { Popper } from '@dhis2/ui-core' + * + * @see Live demo: {@link /demo/?path=/story/popper--default|Storybook} + * @see Popper js: {@link https://popper.js.org/docs/v2/|Documentation} + */ + +class Popper extends Component { + popperInstance = null + popperRef = createRef() + + componentDidMount() { + const { reference, strategy, onFirstUpdate, placement } = this.props + + this.popperInstance = createPopper( + reference.current, + this.popperRef.current, + { + strategy, + onFirstUpdate, + placement, + modifiers: this.deduplicateModifiers(), + } + ) + } + + deduplicateModifiers() { + const { modifiers } = this.props + // Deduplicate modifiers from props and baseModifiers, + // when duplicates are encountered (by name), use the + // modifier from props so each Popper can be fully custom + return Object.keys(baseModifiers) + .map(key => baseModifiers[key]) + .filter(({ name }) => !modifiers.some(m => m.name === name)) + .concat(modifiers) + } + + componentWillUnmount() { + this.popperInstance && this.popperInstance.destroy() + this.popperInstance = null + } + + render() { + const { children, dataTest, className } = this.props + + return ( +
    + {children} +
    + ) + } +} + +Popper.defaultProps = { + dataTest: 'dhis2-uicore-popper', + modifiers: [], +} + +/** + * @typedef Modifier + * @type {Object} + * @property {string} name + * @property {Object} options + */ + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {Node} children + * @prop {React.Ref} reference A React ref that refers to the element the Popover should position against + * @prop {string} [className] + * @prop {string} [dataTest=dhis2-uicore-popper] + * @prop {Array.} [modifiers=[]] A property of the `createPopper` options, {@link https://popper.js.org/docs/v2/constructors/|see constructor section of popper.js docs} + * @prop {('absolute'|'fixed')} [strategy=absolute] A property of the `createPopper` options, {@link https://popper.js.org/docs/v2/constructors/|see constructor section of popper.js docs} + * @prop {Function} [onFirstUpdate] A property of the `createPopper` options, {@link https://popper.js.org/docs/v2/constructors/|see constructor section of popper.js docs} + * @prop {('auto'|'auto-start'|'auto-end'|'top'|'top-start'|'top-end'|'bottom'|'bottom-start'|'bottom-end'|'right'|'right-start'|'right-end'|'left'|'left-start'|'left-end')} [placement=top] A property of the `createPopper` options, {@link https://popper.js.org/docs/v2/constructors/|see constructor section of popper.js docs} + */ +// Prop names follow the names here: https://popper.js.org/docs/v2/constructors/ +Popper.propTypes = { + children: propTypes.node.isRequired, + reference: sharedPropTypes.elementRefPropType.isRequired, + className: propTypes.string, + dataTest: propTypes.string, + modifiers: propTypes.arrayOf( + propTypes.shape({ + name: propTypes.string, + options: propTypes.object, + }) + ), + placement: sharedPropTypes.referencePlacementPropType, + strategy: propTypes.oneOf(['absolute', 'fixed']), // defaults to 'absolute' + onFirstUpdate: propTypes.func, +} + +export { Popper } diff --git a/packages/core/src/Popper/Popper.stories.e2e.js b/packages/core/src/Popper/Popper.stories.e2e.js new file mode 100644 index 0000000000..e870ec261e --- /dev/null +++ b/packages/core/src/Popper/Popper.stories.e2e.js @@ -0,0 +1,145 @@ +import React, { Component, createRef } from 'react' +import propTypes from '@dhis2/prop-types' + +import { Popper } from './Popper.js' + +const boxStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 400, + height: 400, + marginBottom: 1000, + backgroundColor: 'aliceblue', +} + +const referenceElementStyle = { + width: 100, + height: 50, + backgroundColor: 'cadetblue', + textAlign: 'center', + padding: 6, +} + +const popperStyle = { + width: 80, + height: 30, + backgroundColor: 'lightblue', + textAlign: 'center', + padding: 6, +} + +class BoxWithCenteredReferenceElement extends Component { + ref = createRef() + + render() { + const { renderChildren } = this.props + return ( +
    +
    + Reference element +
    + {renderChildren({ referenceElement: this.ref })} +
    + ) + } +} +BoxWithCenteredReferenceElement.propTypes = { + renderChildren: propTypes.func, +} + +const PopperWithStyledContent = ({ referenceElement, placement }) => ( + +
    Popper
    +
    +) +PopperWithStyledContent.propTypes = { + placement: propTypes.string, + referenceElement: propTypes.object, +} + +export default { + title: 'Popper', + component: Popper, + decorators: [ + storyFN => , + ], +} + +/* eslint-disable react/prop-types */ +export const Top = ({ referenceElement }) => ( + +) +export const TopStart = ({ referenceElement }) => ( + +) +export const TopEnd = ({ referenceElement }) => ( + +) +export const Bottom = ({ referenceElement }) => ( + +) +export const BottomStart = ({ referenceElement }) => ( + +) +export const BottomEnd = ({ referenceElement }) => ( + +) +export const Right = ({ referenceElement }) => ( + +) +export const RightStart = ({ referenceElement }) => ( + +) +export const RightEnd = ({ referenceElement }) => ( + +) +export const Left = ({ referenceElement }) => ( + +) +export const LeftStart = ({ referenceElement }) => ( + +) +export const LeftEnd = ({ referenceElement }) => ( + +) diff --git a/packages/core/src/Popper/Popper.stories.js b/packages/core/src/Popper/Popper.stories.js new file mode 100644 index 0000000000..ba60487b38 --- /dev/null +++ b/packages/core/src/Popper/Popper.stories.js @@ -0,0 +1,123 @@ +import React, { Component, createRef } from 'react' +import propTypes from '@dhis2/prop-types' + +import { Popper } from './Popper.js' + +export default { + title: 'Component/Widget/Popper', + component: Popper, + decorators: [ + storyFN => , + ], +} + +const boxStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 400, + height: 400, + marginBottom: 1000, + backgroundColor: 'aliceblue', +} + +const referenceElementStyle = { + width: 130, + height: 50, + backgroundColor: 'cadetblue', + textAlign: 'center', + padding: 6, +} + +const popperStyle = { + width: 110, + height: 30, + backgroundColor: 'lightblue', + textAlign: 'center', + padding: 6, +} + +class BoxWithCenteredReferenceElement extends Component { + ref = createRef() + + render() { + const { renderChildren } = this.props + return ( +
    +
    + Reference element +
    + {renderChildren({ referenceElement: this.ref })} +
    + ) + } +} +BoxWithCenteredReferenceElement.propTypes = { + renderChildren: propTypes.func, +} + +/* eslint-disable react/prop-types */ +export const Top = ({ referenceElement }) => ( + +
    Top
    +
    +) +export const TopStart = ({ referenceElement }) => ( + +
    Top start
    +
    +) +export const TopEnd = ({ referenceElement }) => ( + +
    Top end
    +
    +) +export const Bottom = ({ referenceElement }) => ( + +
    Bottom
    +
    +) +export const BottomStart = ({ referenceElement }) => ( + +
    Bottom start
    +
    +) +export const BottomEnd = ({ referenceElement }) => ( + +
    Bottom end
    +
    +) +export const Right = ({ referenceElement }) => ( + +
    Right
    +
    +) +export const RightStart = ({ referenceElement }) => ( + +
    Right start
    +
    +) +export const RightEnd = ({ referenceElement }) => ( + +
    Right end
    +
    +) +export const Left = ({ referenceElement }) => ( + +
    Left
    +
    +) +export const LeftStart = ({ referenceElement }) => ( + +
    Left start
    +
    +) +export const LeftEnd = ({ referenceElement }) => ( + +
    Left end
    +
    +) diff --git a/packages/core/src/Popper/modifiers.js b/packages/core/src/Popper/modifiers.js new file mode 100644 index 0000000000..dadc8d9e76 --- /dev/null +++ b/packages/core/src/Popper/modifiers.js @@ -0,0 +1,14 @@ +export const preventOverflow = { + name: 'preventOverflow', + options: { + altAxis: true, + rootBoundary: 'document', + }, +} + +export const flip = { + name: 'flip', + options: { + rootBoundary: 'document', + }, +} diff --git a/packages/core/src/Radio/Radio.js b/packages/core/src/Radio/Radio.js new file mode 100644 index 0000000000..86b8d022bb --- /dev/null +++ b/packages/core/src/Radio/Radio.js @@ -0,0 +1,210 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React, { Component, createRef } from 'react' + +import { Regular, Dense } from '@dhis2/ui-icons' +import { colors, theme, sharedPropTypes } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * + * @param {Radio.PropTypes} props + * @returns {React.Component} + * + * @example import { Radio } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/radio.md|Design system} + * @see Live demo: {@link /demo/?path=/story/radio--default|Storybook} + */ +class Radio extends Component { + ref = createRef() + + componentDidMount() { + if (this.props.initialFocus) { + this.ref.current.focus() + } + } + + handleChange = e => { + if (this.props.onChange) { + this.props.onChange(this.createHandlerPayload(), e) + } + } + + handleBlur = e => { + if (this.props.onBlur) { + this.props.onBlur(this.createHandlerPayload(), e) + } + } + + handleFocus = e => { + if (this.props.onFocus) { + this.props.onFocus(this.createHandlerPayload(), e) + } + } + + createHandlerPayload() { + return { + value: this.props.value, + name: this.props.name, + checked: !this.props.checked, + } + } + + render() { + const { + checked = false, + className, + disabled, + error, + label, + name, + tabIndex, + valid, + value, + warning, + dense, + dataTest, + } = this.props + + const classes = cx({ + checked, + disabled, + valid, + error, + warning, + }) + + return ( + + ) + } +} + +Radio.defaultProps = { + dataTest: 'dhis2-uicore-radio', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [value] + * @prop {Node} [label] + * @prop {function} [onChange] - called with the signature `object, event` + * @prop {string} [name] + * @prop {string} [className] + * @prop {string} [tabIndex] + * + * @prop {boolean} [disabled] + * @prop {boolean} [checked] + * @prop {boolean} [initialFocus] + * + * @prop {boolean} [valid] - `valid`, `warning`, and `error` are + * mutually exclusive + * @prop {boolean} [warning] + * @prop {boolean} [error] + * + * @prop {boolean} [dense] + * + * @prop {function} [onFocus] + * @prop {function} [onBlur] + * @prop {string} [dataTest] + */ +Radio.propTypes = { + checked: propTypes.bool, + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + disabled: propTypes.bool, + error: sharedPropTypes.statusPropType, + initialFocus: propTypes.bool, + label: propTypes.node, + name: propTypes.string, + tabIndex: propTypes.string, + valid: sharedPropTypes.statusPropType, + value: propTypes.string, + warning: sharedPropTypes.statusPropType, + onBlur: propTypes.func, + onChange: propTypes.func, + onFocus: propTypes.func, +} + +export { Radio } diff --git a/packages/core/src/Radio/Radio.stories.e2e.js b/packages/core/src/Radio/Radio.stories.e2e.js new file mode 100644 index 0000000000..858d50684f --- /dev/null +++ b/packages/core/src/Radio/Radio.stories.e2e.js @@ -0,0 +1,44 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' +import { Radio } from './Radio.js' + +window.onChange = window.Cypress && window.Cypress.cy.stub() +window.onBlur = window.Cypress && window.Cypress.cy.stub() +window.onFocus = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Radio', module) + .add('With onChange', () => ( + + )) + .add('With initialFocus and onBlur', () => ( + + )) + .add('With onFocus', () => ( + + )) + .add('With disabled', () => ( + + )) + .add('With label', () => ( + + )) + .add('With initialFocus', () => ( + + )) diff --git a/packages/core/src/Radio/Radio.stories.js b/packages/core/src/Radio/Radio.stories.js new file mode 100644 index 0000000000..1ee62e3254 --- /dev/null +++ b/packages/core/src/Radio/Radio.stories.js @@ -0,0 +1,373 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' + +import { Radio } from './Radio.js' + +window.onChange = (payload, event) => { + console.log('onChange payload', payload) + console.log('onChange event', event) +} + +window.onFocus = (payload, event) => { + console.log('onFocus payload', payload) + console.log('onFocus event', event) +} + +window.onBlur = (payload, event) => { + console.log('onBlur payload', payload) + console.log('onBlur event', event) +} + +const onChange = (...args) => window.onChange(...args) +const onFocus = (...args) => window.onFocus(...args) +const onBlur = (...args) => window.onBlur(...args) + +storiesOf('Component/Core/Radio', module) + // Regular + .add('Default', () => ( + + )) + + .add('Focused unchecked', () => ( + <> + + + + )) + + .add('Focused checked', () => ( + <> + + + + )) + + .add('Checked', () => ( + + )) + + .add('Disabled', () => ( + <> + + + + )) + + .add('Valid', () => ( + <> + + + + )) + + .add('Warning', () => ( + <> + + + + )) + + .add('Error', () => ( + <> + + + + )) + + .add('Image label', () => ( + } + value="with-help" + onChange={onChange} + onFocus={onFocus} + onBlur={onBlur} + /> + )) + + // Dense + .add('Default - Dense', () => ( + + )) + + .add('Focused unchecked - Dense', () => ( + + )) + + .add('Focused checked - Dense', () => ( + + )) + + .add('Checked - Dense', () => ( + + )) + + .add('Disabled - Dense', () => ( + <> + + + + )) + + .add('Valid - Dense', () => ( + <> + + + + )) + + .add('Warning - Dense', () => ( + <> + + + + )) + + .add('Error - Dense', () => ( + <> + + + + )) + + .add('Image label - Dense', () => ( + } + value="with-help" + onChange={onChange} + onFocus={onFocus} + onBlur={onBlur} + /> + )) diff --git a/packages/core/src/Required/Required.js b/packages/core/src/Required/Required.js new file mode 100644 index 0000000000..fd7f0524e5 --- /dev/null +++ b/packages/core/src/Required/Required.js @@ -0,0 +1,18 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { spacers } from '@dhis2/ui-constants' + +export const Required = ({ dataTest }) => ( + + * + + +) + +Required.propTypes = { + dataTest: propTypes.string.isRequired, +} diff --git a/packages/core/src/ScreenCover/ScreenCover.js b/packages/core/src/ScreenCover/ScreenCover.js new file mode 100644 index 0000000000..62bd63e861 --- /dev/null +++ b/packages/core/src/ScreenCover/ScreenCover.js @@ -0,0 +1,86 @@ +import React from 'react' + +import propTypes from '@dhis2/prop-types' +import { layers, spacers, sharedPropTypes } from '@dhis2/ui-constants' + +import { Backdrop } from '../Backdrop/Backdrop.js' + +const Content = ({ children, position }) => ( +
    + {children} + +
    +) + +Content.propTypes = { + children: propTypes.node, + position: sharedPropTypes.insideAlignmentPropType, +} + +/** + * @module + * + * @param {ScreenCover.PropTypes} props + * @returns {React.Component} + * + * @example import { ScreenCover } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/principles/spacing-alignment.md#stacking|Design system} + * @see Live demo: {@link /demo/?path=/story/screencover--default|Storybook} + */ +const ScreenCover = ({ children, onClick, className, dataTest, position }) => { + return ( + + {children} + + ) +} + +ScreenCover.defaultProps = { + dataTest: 'dhis2-uicore-screencover', + position: 'middle', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {function} [onClick] + * @prop {string} [className] + * @prop {Node} [children] + * @prop {string} [dataTest] + */ +ScreenCover.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + position: sharedPropTypes.insideAlignmentPropType, + onClick: propTypes.func, +} + +export { ScreenCover } diff --git a/packages/core/src/ScreenCover/ScreenCover.stories.e2e.js b/packages/core/src/ScreenCover/ScreenCover.stories.e2e.js new file mode 100644 index 0000000000..4fe4c6eee6 --- /dev/null +++ b/packages/core/src/ScreenCover/ScreenCover.stories.e2e.js @@ -0,0 +1,9 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { ScreenCover } from './ScreenCover.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() + +storiesOf('ScreenCover', module) + .add('With onClick', () => ) + .add('With children', () => I am a child) diff --git a/packages/core/src/ScreenCover/ScreenCover.stories.js b/packages/core/src/ScreenCover/ScreenCover.stories.js new file mode 100644 index 0000000000..c54109df68 --- /dev/null +++ b/packages/core/src/ScreenCover/ScreenCover.stories.js @@ -0,0 +1,147 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { CircularLoader, Menu, MenuItem, Divider } from '../index.js' + +import { ScreenCover } from './ScreenCover.js' + +window.onClick = (payload, event) => { + console.log('onClick payload', payload) + console.log('onClick event', event) +} + +const onClick = (...args) => window.onClick(...args) + +storiesOf('Utility/ScreenCover', module) + .add('Default', () => ) + + .add('CircularLoader', () => ( + + + + )) + + .add('Align top: Menu', () => ( + + + + + + + + + { + alert(`this is ${val}`) + }} + /> + + + + + + + + + { + alert(`this is ${val}`) + }} + /> + { + alert(`this is ${val}`) + }} + /> + + + )) + + .add('Align middle: Menu', () => ( + + + + + + + + + { + alert(`this is ${val}`) + }} + /> + + + + + + + + + { + alert(`this is ${val}`) + }} + /> + { + alert(`this is ${val}`) + }} + /> + + + )) + + .add('Align bottom: Menu', () => ( + + + + + + + + + { + alert(`this is ${val}`) + }} + /> + + + + + + + + + { + alert(`this is ${val}`) + }} + /> + { + alert(`this is ${val}`) + }} + /> + + + )) diff --git a/packages/core/src/Select/ArrowDown.js b/packages/core/src/Select/ArrowDown.js new file mode 100644 index 0000000000..78dce83598 --- /dev/null +++ b/packages/core/src/Select/ArrowDown.js @@ -0,0 +1,37 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +export const ArrowDown = ({ className }) => ( + + + + + + +) + +ArrowDown.propTypes = { + className: propTypes.string, +} diff --git a/packages/core/src/Select/Empty.js b/packages/core/src/Select/Empty.js new file mode 100644 index 0000000000..bef37e189c --- /dev/null +++ b/packages/core/src/Select/Empty.js @@ -0,0 +1,27 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors, spacers, theme } from '@dhis2/ui-constants' + +const Empty = ({ message, className, dataTest }) => ( +
    + {message} + +
    +) + +Empty.propTypes = { + dataTest: propTypes.string.isRequired, + message: propTypes.string.isRequired, + className: propTypes.string, +} + +export { Empty } diff --git a/packages/core/src/Select/FilterInput.js b/packages/core/src/Select/FilterInput.js new file mode 100644 index 0000000000..9f39730892 --- /dev/null +++ b/packages/core/src/Select/FilterInput.js @@ -0,0 +1,39 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { Input } from '../Input/Input.js' +import { spacers, colors } from '@dhis2/ui-constants' + +const FilterInput = ({ value, onChange, placeholder, className, dataTest }) => ( +
    + + + +
    +) + +FilterInput.propTypes = { + dataTest: propTypes.string.isRequired, + value: propTypes.string.isRequired, + className: propTypes.string, + placeholder: propTypes.string, + onChange: propTypes.func, +} + +export { FilterInput } diff --git a/packages/core/src/Select/FilterableMenu.js b/packages/core/src/Select/FilterableMenu.js new file mode 100644 index 0000000000..bdfa0c0b69 --- /dev/null +++ b/packages/core/src/Select/FilterableMenu.js @@ -0,0 +1,107 @@ +import React, { Component } from 'react' +import propTypes from '@dhis2/prop-types' +import { sharedPropTypes } from '@dhis2/ui-constants' +import { FilterInput } from '../Select/FilterInput.js' +import { NoMatch } from '../Select/NoMatch.js' +import { filterIgnored, checkIfValidOption } from '../Select/option-helpers.js' + +export class FilterableMenu extends Component { + state = { + filter: '', + } + + onFilterChange = ({ value }) => { + this.setState({ filter: value }) + } + + render() { + const { + dataTest, + options, + onChange, + selected, + empty, + handleClose, + handleFocusInput, + placeholder, + noMatchText, + Menu, + } = this.props + const { filter } = this.state + const menuProps = { + onChange, + selected, + empty, + handleClose, + handleFocusInput, + dataTest, + } + + const renderedOptions = filterIgnored(options) + + // If there are no options or there's no filter, just pass everything through + if (React.Children.count(renderedOptions) === 0 || !filter) { + return ( + + + + + ) + } + + const filtered = React.Children.map(options, child => { + const isValidOption = checkIfValidOption(child) + + // Filter it out if it's an invalid option + if (!isValidOption) { + return null + } + + const { label } = child.props + + // Filter by label, because that's the part of an option that's displayed to the user + const match = label.toLowerCase().includes(filter.toLowerCase()) + + return match ? child : null + }) + + const hasMatch = React.Children.count(filtered) > 0 + + return ( + + + {hasMatch ? ( + + ) : ( + + )} + + ) + } +} + +FilterableMenu.propTypes = { + Menu: propTypes.elementType.isRequired, + dataTest: propTypes.string.isRequired, + noMatchText: propTypes.string.isRequired, + selected: propTypes.oneOfType([ + sharedPropTypes.singleSelectedPropType, + sharedPropTypes.multiSelectedPropType, + ]).isRequired, + empty: propTypes.node, + handleClose: propTypes.func, + handleFocusInput: propTypes.func, + options: propTypes.node, + placeholder: propTypes.string, + onChange: propTypes.func, +} diff --git a/packages/core/src/Select/InputClearButton.js b/packages/core/src/Select/InputClearButton.js new file mode 100644 index 0000000000..9d300b92a2 --- /dev/null +++ b/packages/core/src/Select/InputClearButton.js @@ -0,0 +1,25 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { Button } from '../Button/Button.js' + +const InputClearButton = ({ onClear, clearText, className, dataTest }) => ( + +) + +InputClearButton.propTypes = { + clearText: propTypes.string.isRequired, + dataTest: propTypes.string.isRequired, + onClear: propTypes.func.isRequired, + className: propTypes.string, +} + +export { InputClearButton } diff --git a/packages/core/src/Select/InputPlaceholder.js b/packages/core/src/Select/InputPlaceholder.js new file mode 100644 index 0000000000..4815f6765b --- /dev/null +++ b/packages/core/src/Select/InputPlaceholder.js @@ -0,0 +1,30 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors } from '@dhis2/ui-constants' + +const InputPlaceholder = ({ placeholder, className, dataTest }) => { + if (!placeholder) { + return null + } + + return ( +
    + {placeholder} + + +
    + ) +} + +InputPlaceholder.propTypes = { + dataTest: propTypes.string.isRequired, + className: propTypes.string, + placeholder: propTypes.string, +} + +export { InputPlaceholder } diff --git a/packages/core/src/Select/InputPrefix.js b/packages/core/src/Select/InputPrefix.js new file mode 100644 index 0000000000..33f0c6c7d8 --- /dev/null +++ b/packages/core/src/Select/InputPrefix.js @@ -0,0 +1,32 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors, spacers } from '@dhis2/ui-constants' + +const InputPrefix = ({ prefix, className, dataTest }) => { + if (!prefix) { + return null + } + + return ( +
    + {prefix} + + +
    + ) +} + +InputPrefix.propTypes = { + dataTest: propTypes.string.isRequired, + className: propTypes.string, + prefix: propTypes.string, +} + +export { InputPrefix } diff --git a/packages/core/src/Select/InputWrapper.js b/packages/core/src/Select/InputWrapper.js new file mode 100644 index 0000000000..2cfaad96dd --- /dev/null +++ b/packages/core/src/Select/InputWrapper.js @@ -0,0 +1,114 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' +import { ArrowDown } from './ArrowDown.js' +import { colors, theme, sharedPropTypes } from '@dhis2/ui-constants' + +const InputWrapper = ({ + dataTest, + onToggle, + children, + tabIndex, + error, + warning, + valid, + disabled, + dense, + className, + inputRef, +}) => { + const classNames = cx(className, 'root', { + error, + warning, + valid, + disabled, + dense, + }) + + return ( +
    +
    {children}
    +
    + +
    + + +
    + ) +} + +InputWrapper.defaultProps = { + tabIndex: '0', +} + +InputWrapper.propTypes = { + dataTest: propTypes.string.isRequired, + inputRef: propTypes.object.isRequired, + tabIndex: propTypes.string.isRequired, + onToggle: propTypes.func.isRequired, + children: propTypes.element, + className: propTypes.string, + dense: propTypes.bool, + disabled: propTypes.bool, + error: sharedPropTypes.statusPropType, + valid: sharedPropTypes.statusPropType, + warning: sharedPropTypes.statusPropType, +} + +export { InputWrapper } diff --git a/packages/core/src/Select/Loading.js b/packages/core/src/Select/Loading.js new file mode 100644 index 0000000000..51cad118fc --- /dev/null +++ b/packages/core/src/Select/Loading.js @@ -0,0 +1,30 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors, spacers, theme } from '@dhis2/ui-constants' +import { CircularLoader } from '../CircularLoader/CircularLoader.js' + +const Loading = ({ message, className, dataTest }) => ( +
    + + {message} + +
    +) + +Loading.propTypes = { + dataTest: propTypes.string.isRequired, + className: propTypes.string, + message: propTypes.string, +} + +export { Loading } diff --git a/packages/core/src/Select/MenuWrapper.js b/packages/core/src/Select/MenuWrapper.js new file mode 100644 index 0000000000..f450df15a0 --- /dev/null +++ b/packages/core/src/Select/MenuWrapper.js @@ -0,0 +1,66 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import propTypes from '@dhis2/prop-types' +import { resolve } from 'styled-jsx/css' +import { Card } from '../Card/Card.js' +import { layers } from '@dhis2/ui-constants' +import { Backdrop } from '../Backdrop/Backdrop.js' + +const MenuWrapper = ({ + children, + maxHeight, + className, + menuRef, + menuTop, + menuLeft, + menuWidth, + zIndex, + onClick, + dataTest, +}) => { + const { styles, className: cardClassName } = resolve` + height: auto; + max-height: ${maxHeight}; + overflow: auto; + ` + return ReactDOM.createPortal( + +
    + {children} + + {styles} + + +
    +
    , + document.body + ) +} + +MenuWrapper.defaultProps = { + maxHeight: '280px', + zIndex: layers.applicationTop, +} + +MenuWrapper.propTypes = { + dataTest: propTypes.string.isRequired, + menuLeft: propTypes.string.isRequired, + menuRef: propTypes.object.isRequired, + menuTop: propTypes.string.isRequired, + menuWidth: propTypes.string.isRequired, + selectRef: propTypes.object.isRequired, + children: propTypes.node, + className: propTypes.string, + maxHeight: propTypes.string, + zIndex: propTypes.number, + onClick: propTypes.func, +} + +export { MenuWrapper } diff --git a/packages/core/src/Select/NoMatch.js b/packages/core/src/Select/NoMatch.js new file mode 100644 index 0000000000..9a4cf249d0 --- /dev/null +++ b/packages/core/src/Select/NoMatch.js @@ -0,0 +1,27 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors, spacers, theme } from '@dhis2/ui-constants' + +const NoMatch = ({ message, className }) => ( +
    + {message} + + +
    +) + +NoMatch.propTypes = { + message: propTypes.string.isRequired, + className: propTypes.string, +} + +export { NoMatch } diff --git a/packages/core/src/Select/Select.js b/packages/core/src/Select/Select.js new file mode 100644 index 0000000000..0951a2d440 --- /dev/null +++ b/packages/core/src/Select/Select.js @@ -0,0 +1,264 @@ +import React, { Component } from 'react' +import propTypes from '@dhis2/prop-types' + +import { sharedPropTypes } from '@dhis2/ui-constants' + +import { InputWrapper } from './InputWrapper.js' +import { MenuWrapper } from './MenuWrapper.js' + +// Keycodes for the keypress event handlers +const ESCAPE_KEY = 27 +const SPACE_KEY = 32 +const UP_KEY = 38 +const DOWN_KEY = 40 + +export class Select extends Component { + state = { + open: false, + menuTop: 'auto', + menuLeft: 'auto', + menuWidth: 'auto', + } + + // The requestAnimationFrame id for updating the menu position + menuRequestId = null + + selectRef = React.createRef() + inputRef = React.createRef() + menuRef = React.createRef() + + componentDidMount() { + if (this.props.initialFocus) { + this.inputRef.current.focus() + } + } + + componentWillUnmount() { + this.handleMeasurementStop() + } + + handleFocusInput = () => { + this.inputRef.current.focus() + } + + onFocus = e => { + const { onFocus, disabled, selected } = this.props + + if (disabled || !onFocus) { + return + } + + onFocus({ selected }, e) + } + + /** + * Menu related logic + */ + updateMenuPosition = () => { + const selectEl = this.selectRef.current + + // Debounce by canceling the previously scheduled measurement + if (this.menuRequestId) { + window.cancelAnimationFrame(this.menuRequestId) + } + + this.menuRequestId = window.requestAnimationFrame(() => { + const rect = selectEl.getBoundingClientRect() + const menuTop = rect.bottom + const menuLeft = rect.left + const menuWidth = rect.width + + const sizing = { + menuTop: `${menuTop}px`, + menuLeft: `${menuLeft}px`, + menuWidth: `${menuWidth}px`, + } + + this.setState(sizing) + }) + } + + handleMeasurementStart = () => { + this.updateMenuPosition() + + window.addEventListener('resize', this.updateMenuPosition) + window.addEventListener('scroll', this.updateMenuPosition) + } + + handleMeasurementStop = () => { + window.removeEventListener('resize', this.updateMenuPosition) + window.removeEventListener('scroll', this.updateMenuPosition) + + if (this.menuRequestId) { + window.cancelAnimationFrame(this.menuRequestId) + } + } + + handleOpen = () => { + this.handleMeasurementStart() + this.setState({ open: true }) + } + + handleClose = () => { + this.setState({ open: false }) + this.handleMeasurementStop() + } + + onToggle = e => { + if (this.props.disabled) { + return + } + + e.stopPropagation() + + this.state.open ? this.handleClose() : this.handleOpen() + } + + onOutsideClick = e => { + const { onBlur, disabled, selected } = this.props + + if (disabled) { + return + } + + this.handleClose() + + if (onBlur) { + onBlur({ selected }, e) + } + } + + onKeyDown = e => { + if (this.props.disabled) { + return + } + + e.stopPropagation() + + const { open } = this.state + const { keyCode } = e + const shouldOpen = + !open && + (keyCode === SPACE_KEY || + keyCode === UP_KEY || + keyCode === DOWN_KEY) + const shouldClose = open && keyCode === ESCAPE_KEY + + if (shouldClose) { + return this.handleClose() + } + + if (shouldOpen) { + return this.handleOpen() + } + } + + render() { + const { open, menuTop, menuLeft, menuWidth } = this.state + const { + children, + className, + selected, + onChange, + tabIndex, + maxHeight, + error, + warning, + valid, + disabled, + dense, + dataTest, + } = this.props + + // We need to update the menu's position on selection because + // that can cause the input area to change size + const handleChange = (data, e) => { + this.updateMenuPosition() + onChange(data, e) + } + + // Create the input + const inputProps = { + selected, + onChange: handleChange, + options: children, + disabled, + } + const input = React.cloneElement(this.props.input, inputProps) + + // Create the menu + const menuProps = { + selected, + onChange: handleChange, + options: children, + handleClose: this.handleClose, + handleFocusInput: this.handleFocusInput, + } + const menu = React.cloneElement(this.props.menu, menuProps) + + return ( +
    + + {input} + + {open && ( + + {menu} + + )} +
    + ) + } +} + +Select.defaultProps = { + dataTest: 'dhis2-uicore-select', +} + +Select.propTypes = { + input: propTypes.element.isRequired, + menu: propTypes.element.isRequired, + selected: propTypes.oneOfType([ + sharedPropTypes.singleSelectedPropType, + sharedPropTypes.multiSelectedPropType, + ]).isRequired, + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + disabled: propTypes.bool, + error: sharedPropTypes.statusPropType, + initialFocus: propTypes.bool, + maxHeight: propTypes.string, + tabIndex: propTypes.string, + valid: sharedPropTypes.statusPropType, + warning: sharedPropTypes.statusPropType, + onBlur: propTypes.func, + onChange: propTypes.func, + onFocus: propTypes.func, +} diff --git a/packages/core/src/Select/option-helpers.js b/packages/core/src/Select/option-helpers.js new file mode 100644 index 0000000000..14e9acb01f --- /dev/null +++ b/packages/core/src/Select/option-helpers.js @@ -0,0 +1,45 @@ +import React from 'react' + +// Check whether an option is valid +export const checkIfValidOption = option => + option && + 'props' in option && + 'value' in option.props && + 'label' in option.props + +// Filters all children that won't be rendered from an array of react children +export const filterIgnored = children => + React.Children.toArray(children).filter( + child => child !== null && child !== false && child !== undefined + ) + +// Find an option in an array of react children +export const findOptionChild = (targetOption, optionChildren) => + React.Children.toArray(optionChildren).find(currentOption => { + if (!currentOption.props) { + return false + } + + const matchesLabel = targetOption.label === currentOption.props.label + const matchesValue = targetOption.value === currentOption.props.value + + return matchesLabel && matchesValue + }) + +// Find an option in an array of option objects +export const findOption = (targetOption, optionArray) => + optionArray.find(currentOption => { + const matchesLabel = targetOption.label === currentOption.label + const matchesValue = targetOption.value === currentOption.value + + return matchesLabel && matchesValue + }) + +// Remove a specific option from an array of options +export const removeOption = (targetOption, optionArray) => + optionArray.filter(currentOption => { + const matchesLabel = targetOption.label === currentOption.label + const matchesValue = targetOption.value === currentOption.value + + return !matchesLabel && !matchesValue + }) diff --git a/packages/core/src/SingleSelect/FilterableMenu.js b/packages/core/src/SingleSelect/FilterableMenu.js new file mode 100644 index 0000000000..e383bc54de --- /dev/null +++ b/packages/core/src/SingleSelect/FilterableMenu.js @@ -0,0 +1,44 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { sharedPropTypes } from '@dhis2/ui-constants' +import { FilterableMenu as CommonFilterableMenu } from '../Select/FilterableMenu.js' +import { Menu } from './Menu.js' + +const FilterableMenu = ({ + dataTest, + options, + onChange, + selected, + empty, + handleClose, + handleFocusInput, + placeholder, + noMatchText, +}) => ( + +) + +FilterableMenu.propTypes = { + dataTest: propTypes.string.isRequired, + noMatchText: propTypes.string.isRequired, + empty: propTypes.node, + handleClose: propTypes.func, + handleFocusInput: propTypes.func, + options: propTypes.node, + placeholder: propTypes.string, + selected: sharedPropTypes.singleSelectedPropType, + onChange: propTypes.func, +} + +export { FilterableMenu } diff --git a/packages/core/src/SingleSelect/Input.js b/packages/core/src/SingleSelect/Input.js new file mode 100644 index 0000000000..91542eb2d6 --- /dev/null +++ b/packages/core/src/SingleSelect/Input.js @@ -0,0 +1,103 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' +import { colors, sharedPropTypes } from '@dhis2/ui-constants' +import { Selection } from './Selection.js' +import { InputPlaceholder } from '../Select/InputPlaceholder.js' +import { InputPrefix } from '../Select/InputPrefix.js' +import { InputClearButton } from '../Select/InputClearButton.js' + +const Input = ({ + selected, + onChange, + clearable, + clearText, + placeholder, + dataTest, + prefix, + options, + className, + disabled, + inputMaxHeight, +}) => { + const hasSelection = 'label' in selected && 'value' in selected + const onClear = (_, e) => { + const data = { selected: {} } + + e.stopPropagation() + onChange(data, e) + } + + return ( +
    + + {!hasSelection && !prefix && ( + + )} + {hasSelection && ( +
    + {/* the wrapper div above is necessary to enforce wrapping on overflow */} + +
    + )} + {hasSelection && clearable && !disabled && ( +
    + +
    + )} + + + + +
    + ) +} + +Input.defaultProps = { + inputMaxHeight: '100px', +} + +Input.propTypes = { + dataTest: propTypes.string.isRequired, + className: propTypes.string, + clearText: propTypes.requiredIf(props => props.clearable, propTypes.string), + clearable: propTypes.bool, + disabled: propTypes.bool, + inputMaxHeight: propTypes.string, + options: propTypes.node, + placeholder: propTypes.string, + prefix: propTypes.string, + selected: sharedPropTypes.singleSelectedPropType, + onChange: propTypes.func, +} + +export { Input } diff --git a/packages/core/src/SingleSelect/Menu.js b/packages/core/src/SingleSelect/Menu.js new file mode 100644 index 0000000000..8a7e806f80 --- /dev/null +++ b/packages/core/src/SingleSelect/Menu.js @@ -0,0 +1,81 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { sharedPropTypes } from '@dhis2/ui-constants' +import { Empty } from '../Select/Empty.js' +import { filterIgnored, checkIfValidOption } from '../Select/option-helpers.js' + +const onIgnoredClick = (_, e) => { + e.stopPropagation() + e.preventDefault() +} + +const Menu = ({ + options, + onChange, + selected, + empty, + handleFocusInput, + handleClose, + dataTest, +}) => { + const renderedOptions = filterIgnored(options) + + if (React.Children.count(renderedOptions) === 0) { + // If it's a string, supply it to our component so it looks better + if (typeof empty === 'string') { + return + } + + // Otherwise just render the supplied markup + return empty + } + + const children = React.Children.map(options, child => { + const isValidOption = checkIfValidOption(child) + + // Return early if the child isn't an option, to prevent attaching handlers etc. + if (!isValidOption) { + return child + } + + const { value, label, disabled: isDisabled } = child.props + + // Active means the option is currently selected + const isActive = value === selected.value && label === selected.label + const onClick = (_, e) => { + const data = { selected: { value, label } } + e.stopPropagation() + + onChange(data, e) + handleClose() + handleFocusInput() + } + + // Clicks on active options or disabled options should be ignored for the single select + const isIgnored = isActive || isDisabled + + return React.cloneElement(child, { + ...child.props, + onClick: isIgnored ? onIgnoredClick : onClick, + active: isActive, + }) + }) + + return {children} +} + +Menu.defaultProps = { + empty: '', +} + +Menu.propTypes = { + dataTest: propTypes.string.isRequired, + empty: propTypes.node, + handleClose: propTypes.func, + handleFocusInput: propTypes.func, + options: propTypes.node, + selected: sharedPropTypes.singleSelectedPropType, + onChange: propTypes.func, +} + +export { Menu } diff --git a/packages/core/src/SingleSelect/Selection.js b/packages/core/src/SingleSelect/Selection.js new file mode 100644 index 0000000000..af44182132 --- /dev/null +++ b/packages/core/src/SingleSelect/Selection.js @@ -0,0 +1,50 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' +import { spacers, sharedPropTypes } from '@dhis2/ui-constants' +import { findOptionChild } from '../Select/option-helpers.js' + +const Selection = ({ options, selected, className }) => { + const selectedOption = findOptionChild(selected, options) + + if (!selectedOption) { + const message = + 'The selected option could not be found as a child of the select. ' + + 'Make sure that the value and label passed to the `selected` prop ' + + 'match an existing option.' + throw new Error(message) + } + + const icon = selectedOption.props.icon + const label = selectedOption.props.label + + return ( +
    + {icon &&
    {icon}
    } + {label} + + +
    + ) +} + +Selection.propTypes = { + className: propTypes.string, + options: propTypes.node, + selected: sharedPropTypes.singleSelectedPropType, +} + +export { Selection } diff --git a/packages/core/src/SingleSelect/SingleSelect.js b/packages/core/src/SingleSelect/SingleSelect.js new file mode 100644 index 0000000000..335979d41d --- /dev/null +++ b/packages/core/src/SingleSelect/SingleSelect.js @@ -0,0 +1,188 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { StatusIcon } from '@dhis2/ui-icons' +import { spacers, sharedPropTypes } from '@dhis2/ui-constants' + +import { Select } from '../Select/Select.js' +import { Loading } from '../Select/Loading.js' + +import { Input } from './Input.js' +import { Menu } from './Menu.js' +import { FilterableMenu } from './FilterableMenu.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * + * @param {SingleSelect.PropTypes} props + * @returns {React.Component} + * + * @example import { SingleSelect } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/select.md|Design system} + */ +const SingleSelect = ({ + className, + selected, + tabIndex, + maxHeight, + inputMaxHeight, + onChange, + onFocus, + onBlur, + loading, + error, + warning, + valid, + disabled, + children, + clearable, + clearText, + filterable, + filterPlaceholder, + placeholder, + prefix, + empty, + loadingText, + noMatchText, + initialFocus, + dense, + dataTest, +}) => { + // If the select is filterable, use a filterable menu + const menu = filterable ? ( + + ) : ( + + ) + + return ( +
    +
    + +
    + + + +
    + ) +} + +SingleSelect.defaultProps = { + selected: {}, + dataTest: 'dhis2-uicore-singleselect', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {function} [onChange] + * @prop {Object} [selected] + * @prop {string} [className] + * @prop {string} [tabIndex] + * @prop {Node} [children] + * @prop {boolean} [disabled] + * @prop {boolean} [dense] + * @prop {boolean} [valid] - `valid`, `warning`, `error`, `loading`, are mutually exclusive + * @prop {boolean} [warning] + * @prop {boolean} [error] + * @prop {boolean} [loading] + * @prop {function} [onFocus] + * @prop {function} [onBlur] + * @prop {boolean} [initialFocus] + * @prop {string} [clearText] - Only required if clearable is true + * @prop {boolean} [clearable] + * @prop {Node} [empty] + * @prop {string} [filterPlaceholder] + * @prop {boolean} [filterable] + * @prop {string} [loadingText] + * @prop {string} [maxHeight] + * @prop {string} [inputMaxHeight] + * @prop {string} [noMatchText] - Only required if filterable is true + * @prop {string} [placeholder] + * @prop {string} [prefix] + * @prop {string} [dataTest] + */ +SingleSelect.propTypes = { + children: propTypes.node, + className: propTypes.string, + clearText: propTypes.requiredIf(props => props.clearable, propTypes.string), + clearable: propTypes.bool, + dataTest: propTypes.string, + dense: propTypes.bool, + disabled: propTypes.bool, + empty: propTypes.node, + error: sharedPropTypes.statusPropType, + filterPlaceholder: propTypes.string, + filterable: propTypes.bool, + initialFocus: propTypes.bool, + inputMaxHeight: propTypes.string, + loading: propTypes.bool, + loadingText: propTypes.string, + maxHeight: propTypes.string, + noMatchText: propTypes.requiredIf( + props => props.filterable, + propTypes.string + ), + placeholder: propTypes.string, + prefix: propTypes.string, + selected: sharedPropTypes.singleSelectedPropType, + tabIndex: propTypes.string, + valid: sharedPropTypes.statusPropType, + warning: sharedPropTypes.statusPropType, + onBlur: propTypes.func, + onChange: propTypes.func, + onFocus: propTypes.func, +} + +export { SingleSelect } diff --git a/packages/core/src/SingleSelect/SingleSelect.stories.e2e.js b/packages/core/src/SingleSelect/SingleSelect.stories.e2e.js new file mode 100644 index 0000000000..7b2fd44744 --- /dev/null +++ b/packages/core/src/SingleSelect/SingleSelect.stories.e2e.js @@ -0,0 +1,244 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { storiesOf } from '@storybook/react' + +import { SingleSelect, SingleSelectOption } from '../index.js' + +window.onChange = window.Cypress && window.Cypress.cy.stub() +window.onFocus = window.Cypress && window.Cypress.cy.stub() +window.onBlur = window.Cypress && window.Cypress.cy.stub() + +const CustomSingleSelectOption = ({ label, onClick }) => ( +
    onClick({}, e)}>{label}
    +) + +CustomSingleSelectOption.propTypes = { + label: propTypes.string, + onClick: propTypes.func, +} + +storiesOf('SingleSelect', module) + .add('With options', () => ( + + + + + + )) + .add('With options and onChange', () => ( + + + + + + )) + .add('With onFocus', () => ( + + + + + + )) + .add('With onBlur', () => ( + + + + + + )) + .add('With custom options and onChange', () => ( + + + + + + )) + .add('With invalid options', () => ( + +
    invalid one
    + +
    invalid two
    + +
    invalid three
    + + {null} + {undefined} + {false} +
    + )) + .add('With invalid filterable options', () => ( + +
    invalid one
    + +
    invalid two
    + +
    invalid three
    + +
    + )) + .add('With initialFocus', () => ( + + )) + .add('Empty', () => ) + .add('Empty with empty text', () => ( + + )) + .add('Empty with empty component', () => ( + Custom empty component} + /> + )) + .add('With options and loading', () => ( + + + + + + )) + .add('With options, loading and loading text', () => ( + + + + + + )) + .add('With more than ten options', () => ( + + + + + + + + + + + + + + + )) + .add('With more than three options and a 100px max-height', () => ( + + + + + + + + + + + + + + + )) + .add('With options, a selection and disabled', () => ( + + + + + + )) + .add('With options and disabled', () => ( + + + + + + )) + .add('With prefix', () => ( + + + + + + )) + .add('With prefix and selection', () => ( + + + + + + )) + .add('With placeholder', () => ( + + + + + + )) + .add('With placeholder and selection', () => ( + + + + + + )) + .add('With disabled option and onChange', () => ( + + + + + + + )) + .add('With options and a selection', () => ( + + + + + + )) + .add('With options, a selection and onChange', () => ( + + + + + + )) + .add('With clear button, selection and onChange', () => ( + + + + + + )) + .add('With filter field', () => ( + + + + + + )) diff --git a/packages/core/src/SingleSelect/SingleSelect.stories.js b/packages/core/src/SingleSelect/SingleSelect.stories.js new file mode 100644 index 0000000000..d7085a6d83 --- /dev/null +++ b/packages/core/src/SingleSelect/SingleSelect.stories.js @@ -0,0 +1,252 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import { SingleSelectOption } from '../index.js' +import { SingleSelect } from './SingleSelect.js' + +const OptionIcon = () => ( + + + + +) + +const CheckIcon = () => ( + + + +) + +const CustomOption = ({ label, description, icon, active, onClick }) => ( + onClick({}, e)}> + {icon &&
    {icon}
    } +
    +

    {label}

    +

    {description}

    +
    + {active && ( +
    + +
    + )} + + +
    +) + +CustomOption.propTypes = { + // This prop is used by the Select, so still necessary + label: propTypes.string.isRequired, + // eslint-disable-next-line react/no-unused-prop-types + value: propTypes.string.isRequired, + active: propTypes.bool, + description: propTypes.string, + icon: propTypes.element, + onClick: propTypes.func, +} + +const longLabel = + 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Quas quibusdam est debitis odio, earum explicabo error magni. Expedita voluptatem accusantium nostrum minus, voluptate mollitia placeat perspiciatis quibusdam dicta, fugiat dolor quisquam alias, impedit commodi? Inventore, laboriosam, molestias. Non quod odit eum adipisci, tempora rerum perferendis, incidunt consectetur nesciunt. Qui voluptatibus asperiores dicta doloremque est vitae tempora sapiente dignissimos maiores saepe, animi necessitatibus modi laboriosam quae blanditiis voluptatem recusandae. Praesentium ipsum dolores voluptatum repudiandae cumque, tempore tempora, excepturi numquam atque reiciendis suscipit alias? Architecto dignissimos enim blanditiis laborum' + +const defaultProps = { + selected: { value: '1', label: 'one' }, + onChange: ({ selected }) => { + alert(`Selected changed to: ${JSON.stringify(selected, null, 2)}`) + }, +} + +const options = [ + , + , + , + , + , + , + , + , + , + , +] + +storiesOf('Component/Core/SingleSelect', module) + .add('Default', () => ( + {options} + )) + .add('Long label', () => ( + + + + + )) + .add('Disabled option', () => ( + + + + + + + + + + + + + )) + .add('Custom options', () => ( + + } + /> + } + /> + } + /> + + )) + .add('No placeholder, no selected options', () => ( + + {options} + + )) + .add('Placeholder, no selected options', () => ( + + {options} + + )) + .add('Prefix, no selected options', () => ( + + {options} + + )) + .add('Prefix, with selected options', () => ( + + {options} + + )) + .add('Status: Loading', () => ( + + {options} + + )) + .add('Status: Loading with text', () => ( + + {options} + + )) + .add('Maximum height', () => ( + + {options} + + )) + .add('Clearable', () => ( + + {options} + + )) + .add('Filterable', () => ( + + {options} + + )) + .add('Empty', () => ) + .add('Custom empty', () => ( + There are no options} + /> + )) + .add('Initial focus', () => ( + + {options} + + )) + .add('Dense', () => ( + + {options} + + )) + .add('Disabled', () => ( + + {options} + + )) + .add('Input max height', () => ( + + + + + )) diff --git a/packages/core/src/SingleSelectOption/SingleSelectOption.js b/packages/core/src/SingleSelectOption/SingleSelectOption.js new file mode 100644 index 0000000000..9c51d08df7 --- /dev/null +++ b/packages/core/src/SingleSelectOption/SingleSelectOption.js @@ -0,0 +1,98 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { colors, spacers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * + * @param {SingleSelectOption.PropTypes} props + * @returns {React.Component} + * + * @example import { SingleSelectOption } from '@dhis2/ui-core' + * + */ + +const SingleSelectOption = ({ + label, + active, + disabled, + onClick, + className, + dataTest, + value, +}) => ( +
    onClick({}, e)} + data-test={dataTest} + data-value={value} + data-label={label} + > + {label} + + +
    +) + +SingleSelectOption.defaultProps = { + dataTest: 'dhis2-uicore-singleselectoption', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} value + * @prop {string} label + * @prop {string} [className] + * @prop {function} [onClick] + * @prop {boolean} [active] + * @prop {boolean} [disabled] + * @prop {string} [dataTest] + */ +SingleSelectOption.propTypes = { + label: propTypes.string.isRequired, + value: propTypes.string.isRequired, + active: propTypes.bool, + className: propTypes.string, + dataTest: propTypes.string, + disabled: propTypes.bool, + onClick: propTypes.func, +} + +export { SingleSelectOption } diff --git a/packages/core/src/SplitButton/SplitButton.js b/packages/core/src/SplitButton/SplitButton.js new file mode 100644 index 0000000000..5c5dc717dc --- /dev/null +++ b/packages/core/src/SplitButton/SplitButton.js @@ -0,0 +1,202 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React, { Component } from 'react' +import { createPortal } from 'react-dom' +import css from 'styled-jsx/css' + +import { ArrowDown, ArrowUp } from '@dhis2/ui-icons' +import { spacers, sharedPropTypes } from '@dhis2/ui-constants' + +import { Backdrop } from '../Backdrop/Backdrop.js' +import { Button } from '../Button/Button.js' +import { Popper } from '../Popper/Popper.js' + +const rightButton = css.resolve` + button { + padding: 0 ${spacers.dp12}; + } +` + +/** + * @module + * + * @param {SplitButton.PropTypes} props + * @returns {React.Component} + * + * @example import { SplitButton } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/button.md|Design system} + * @see Live demo: {@link /demo/?path=/story/splitbutton-basic--default|Storybook} + */ +class SplitButton extends Component { + state = { + open: false, + } + anchorRef = React.createRef() + + onClick = (payload, event) => { + if (this.props.onClick) { + this.props.onClick( + { + name: payload.name, + value: payload.value, + open: this.state.open, + }, + event + ) + } + } + + onToggle = () => this.setState({ open: !this.state.open }) + + render() { + const { open } = this.state + const { + component, + children, + className, + name, + value, + icon, + small, + large, + primary, + secondary, + destructive, + disabled, + type, + tabIndex, + dataTest, + initialFocus, + } = this.props + + const arrow = open ? : + + return ( +
    + + + + + {open && + createPortal( + + + {component} + + , + document.body + )} + + {rightButton.styles} + +
    + ) + } +} + +SplitButton.defaultProps = { + dataTest: 'dhis2-uicore-splitbutton', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Element} [component] + * @prop {string} [children] + * @prop {string} [className] + * @prop {string} [name] + * @prop {string} [value] + * @prop {string} [tabIndex] + * @prop {function} [onClick] + * @prop {Element} [icon] + * @prop {boolean} [small] - `small` and `large` are mutually exclusive + * @prop {boolean} [large] + * @prop {string} [type] Type of button: `submit`, `reset`, or + * `button` + * @prop {boolean } [primary] - `primary`, `secondary`, and + * `destructive` are mutually exclusive boolean props + * @prop {boolean } [secondary] + * @prop {boolean } [destructive] + * @prop {boolean } [disabled] + * @prop {boolean} [initialFocus] Grants the button the initial focus + * @prop {string} [dataTest] + */ +SplitButton.propTypes = { + children: propTypes.string, + className: propTypes.string, + component: propTypes.element, + dataTest: propTypes.string, + destructive: sharedPropTypes.buttonVariantPropType, + disabled: propTypes.bool, + icon: propTypes.element, + initialFocus: propTypes.bool, + large: sharedPropTypes.sizePropType, + name: propTypes.string, + primary: sharedPropTypes.buttonVariantPropType, + secondary: sharedPropTypes.buttonVariantPropType, + small: sharedPropTypes.sizePropType, + tabIndex: propTypes.string, + type: propTypes.oneOf(['submit', 'reset', 'button']), + value: propTypes.string, + onClick: propTypes.func, +} + +export { SplitButton } diff --git a/packages/core/src/SplitButton/SplitButton.stories.e2e.js b/packages/core/src/SplitButton/SplitButton.stories.e2e.js new file mode 100644 index 0000000000..782cc10432 --- /dev/null +++ b/packages/core/src/SplitButton/SplitButton.stories.e2e.js @@ -0,0 +1,65 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { SplitButton } from './SplitButton.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() + +storiesOf('SplitButton', module) + .add('Default', () => ( + Component} + > + Label me! + + )) + .add('With onClick', () => ( + Component} + onClick={window.onClick} + > + Label me! + + )) + .add('With children', () => ( + Component} + > + I am a child + + )) + .add('With icon', () => ( + Component} + icon={
    Icon
    } + > + Children +
    + )) + .add('With initialFocus', () => ( + Component} + initialFocus + > + Children + + )) + .add('With disabled', () => ( + Component} + disabled + > + Children + + )) diff --git a/packages/core/src/SplitButton/SplitButton.stories.js b/packages/core/src/SplitButton/SplitButton.stories.js new file mode 100644 index 0000000000..44600ca333 --- /dev/null +++ b/packages/core/src/SplitButton/SplitButton.stories.js @@ -0,0 +1,76 @@ +import React from 'react' +import { SplitButton } from './SplitButton.js' + +export default { + title: 'Component/Core/SplitButton', + component: SplitButton, +} + +window.onClick = (payload, event) => { + console.log('onClick payload', payload) + console.log('onClick event', event) +} + +const onClick = (...args) => window.onClick(...args) + +const Simple = Simplest thing + +export const Default = () => ( + + Label me! + +) + +export const WithClick = () => ( + + Label me! + +) + +export const Primary = () => ( + + Label me! + +) + +export const Secondary = () => ( + + Label me! + +) + +export const Destructive = () => ( + + Label me! + +) + +export const Disabled = () => ( + + Label me! + +) + +export const Small = () => ( + + Label me! + +) + +export const Large = () => ( + + Label me! + +) + +export const WithMenu = () => ( + + Label me! + +) + +export const InitialFocus = () => ( + + Label me! + +) diff --git a/packages/core/src/StackedTable/ContentWithTitle.js b/packages/core/src/StackedTable/ContentWithTitle.js new file mode 100644 index 0000000000..ad6b38182e --- /dev/null +++ b/packages/core/src/StackedTable/ContentWithTitle.js @@ -0,0 +1,41 @@ +import React, { Fragment } from 'react' + +import propTypes from '@dhis2/prop-types' +import { colors } from '@dhis2/ui-constants' + +export const ContentWithTitle = ({ title, children }) => ( + + {title && {title}} + {children} + + + +) + +ContentWithTitle.propTypes = { + children: propTypes.node.isRequired, + title: propTypes.string, +} diff --git a/packages/core/src/StackedTable/StackedTable.js b/packages/core/src/StackedTable/StackedTable.js new file mode 100644 index 0000000000..7ad4708358 --- /dev/null +++ b/packages/core/src/StackedTable/StackedTable.js @@ -0,0 +1,54 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { extractHeaderLabels } from './extractHeaderLabels.js' +import { Provider } from './TableContext.js' +import { Table } from './Table.js' + +/** + * @module + * @param {StackedTable.PropTypes} + * @returns {React.Component} + * @example import { StackedTable } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/stackedtable--default|Storybook} + */ +export const StackedTable = ({ + children, + className, + dataTest, + headerLabels, +}) => { + const contextHeaderLabels = extractHeaderLabels(children) + const context = { + headerLabels: headerLabels || contextHeaderLabels, + } + + return ( + + + {children} +
    +
    + ) +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + * @prop {string[]} [headerLabels] + * If a specific column should not have a header, + * an empty string must be provided + */ +StackedTable.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + headerLabels: propTypes.arrayOf(propTypes.string), +} + +StackedTable.defaultProps = { + dataTest: 'dhis2-uicore-stackedtable', +} diff --git a/packages/core/src/StackedTable/StackedTable.stories.js b/packages/core/src/StackedTable/StackedTable.stories.js new file mode 100644 index 0000000000..c382d4c102 --- /dev/null +++ b/packages/core/src/StackedTable/StackedTable.stories.js @@ -0,0 +1,451 @@ +/* eslint-disable react/no-unescaped-entities */ +import React from 'react' + +import { storiesOf } from '@storybook/react' + +import { Button } from '../index.js' + +import { StackedTable } from './StackedTable.js' +import { StackedTableBody } from './StackedTableBody.js' +import { StackedTableCell } from './StackedTableCell.js' +import { StackedTableCellHead } from './StackedTableCellHead.js' +import { StackedTableFoot } from './StackedTableFoot.js' +import { StackedTableHead } from './StackedTableHead.js' +import { StackedTableRow } from './StackedTableRow.js' +import { StackedTableRowHead } from './StackedTableRowHead.js' + +const CustomCell = props => ( + + Received props: + +
    {JSON.stringify(props, null, 2)}
    +
    + +) + +storiesOf('Component/Core/StackedTable', module) + .add('Default', () => ( + + + + First name + Last name + Incident date + Last updated + Age + + Registering unit + + Assigned user + Status + + + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + Incomplete + + + + + + + + + + + )) + .add('Hidden label', () => ( + + + + First name + Last name + Incident date + Last updated + Age + + Registering unit + + Assigned user + + + + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + + + + + + + )) + .add('Hidden label in cell', () => ( + + + + First name + Last name + Incident date + Last updated + Age + + Registering unit + + Assigned user + Status + + + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + + + + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + Incomplete + + + + )) + .add('Colspan in header', () => ( + + + + + Name + + Incident date + Last updated + Age + + Registering unit + + Assigned user + Status + + + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + Incomplete + + + + )) + .add('Colspan in body', () => ( + + + + First name + Last name + Incident date + Last updated + Age + + Registering unit + + Assigned user + Status + + + + + Onyekachukwu + Kariuki + + Colspan 2 here. Next cell doesn't get header "Last + updated". + + 66 + Jawi + Sofie Hubert + Incomplete + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + Incomplete + + + + )) + .add('Multiple header rows', () => ( + + + + + Name + + Incident date + Last updated + Age + + Registering unit + + Assigned user + Status + + + First name + Last name + + + + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + Incomplete + + + + )) + .add('Long title', () => ( + + + + + This title is so long, it should be displayed in + multiple lines. Lorem ipsum dolor sit amet, consetetur + sadipscing elitr, sed diam nonumy eirmod tempor invidunt + ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et + ea rebum. Stet clita kasd gubergren, no sea takimata + sanctus est Lorem ipsum dolor sit amet. Lorem ipsum + dolor sit amet, consetetur sadipscing elitr, sed diam + nonumy eirmod tempor invidunt ut labore et dolore magna + aliquyam erat, sed diam voluptua. At vero eos et accusam + et justo duo dolores et ea rebum. Stet clita kasd + gubergren, no sea takimata sanctus est Lorem ipsum dolor + sit amet. + + + + + + Onyekachukwu + + + + )) + .add('Custom cell', () => ( + + + + + Name + + Incident date + + + + + Onyekachukwu + Kariuki + 02/06/2007 + + + + )) + .add('Custom cell title', () => ( + + + + + Name + + Incident date + + + + + Onyekachukwu + + Kariuki + + 02/06/2007 + + + + )) + .add('Larger table', () => ( + + + + First name + Last name + Incident date + Last updated + Age + + Registering unit + + Assigned user + Status + + + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + Incomplete + + + Kwasi + Okafor + 08/11/2010 + 02/26/1991 + 38 + Mokassie MCHP + Dashonte Clarke + Complete + + + Siyabonga + Abiodun + 07/21/1981 + 02/06/2007 + 98 + Bathurst MCHP + Unassigned + Incomplete + + + Chiyembekezo + Okeke + 01/23/1982 + 07/15/2003 + 2 + Mayolla MCHP + Wan Gengxin + Incomplete + + + Mtendere + Afolayan + 08/12/1994 + 05/12/1972 + 37 + Gbangadu MCHP + Gvozden Boskovsky + Complete + + + Inyene + Okonkwo + 04/01/1971 + 03/16/2000 + 70 + Kunike Barina + Oscar de la Cavallería + Complete + + + Amaka + Pretorius + 01/25/1996 + 09/15/1986 + 32 + Bargbo + Alberto Raya + Incomplete + + + Meti + Abiodun + 10/24/2010 + 07/26/1989 + 8 + Majihun MCHP + Unassigned + Complete + + + Eshe + Okeke + 01/31/1995 + 01/31/1995 + 63 + Mambiama CHP + Shadrias Pearson + Incomplete + + + Obi + Okafor + 06/07/1990 + 01/03/2006 + 28 + Dalakuru CHP + Anatoliy Shcherbatykh + Incomplete + + + + + + + + + + + )) diff --git a/packages/core/src/StackedTable/StackedTable.test.js b/packages/core/src/StackedTable/StackedTable.test.js new file mode 100644 index 0000000000..fd33f9ce40 --- /dev/null +++ b/packages/core/src/StackedTable/StackedTable.test.js @@ -0,0 +1,129 @@ +/* eslint-disable react/prop-types */ +import React from 'react' + +import { mount } from 'enzyme' + +import { StackedTable } from './StackedTable.js' +import { StackedTableBody } from './StackedTableBody.js' +import { StackedTableCell } from './StackedTableCell.js' +import { StackedTableCellHead } from './StackedTableCellHead.js' +import { StackedTableHead } from './StackedTableHead.js' +import { StackedTableRow } from './StackedTableRow.js' +import { StackedTableRowHead } from './StackedTableRowHead.js' + +const Table = ({ headerLabels, bodyLabels }) => ( + + + + {headerLabels.map(({ label, ...props }, index) => ( + + {label} + + ))} + + + + + {bodyLabels.map(({ label, ...props }) => ( + + {label} + + ))} + + + +) + +describe('StackedTable', () => { + const headerLabels = [ + { label: 'First name', colSpan: '1' }, + { label: 'Last name', colSpan: '1' }, + { label: 'Incident date', colSpan: '1' }, + { label: 'Last updated', colSpan: '1' }, + { label: 'Age', colSpan: '1' }, + { label: 'Registering unit', colSpan: '1' }, + { label: 'Assigned user', colSpan: '1' }, + { label: 'Status', colSpan: '1' }, + ] + + const bodyLabels = [ + { label: 'Onyekachukwu' }, + { label: 'Kariuki' }, + { label: '02/06/2007' }, + { label: '05/25/1972' }, + { label: '66' }, + { label: 'Jawi' }, + { label: 'Sofie Hubert' }, + { label: 'Incomplete' }, + ] + + it('should add the headerLabels to each cell', () => { + const table = mount( + + ) + const cells = table.find('td') + + headerLabels.forEach((label, index) => { + const cell = cells.at(index) + const title = cell.find('.title').text() + + expect(title).toBe(headerLabels[index].label) + }) + }) + + it('should not add empty header labels to the body cells', () => { + const emptyLabelIndex = 2 + const headerLabelsWithEmpty = [ + ...headerLabels.slice(0, emptyLabelIndex), + '', + ...headerLabels.slice(emptyLabelIndex + 1), + ] + const table = mount( +
    + ) + const cells = table.find('td') + const cell = cells.at(emptyLabelIndex) + const title = cell.find('.title') + + expect(title).toHaveLength(0) + }) + + it('should add a header label with a colspan higher than 1 to multiple body cells', () => { + const indexWithColspan = 1 + const colSpan = 3 + const originalHeaderLabel = headerLabels[indexWithColspan] + const headerLabelsWithColspan = [ + ...headerLabels.slice(0, indexWithColspan), + { ...originalHeaderLabel, colSpan: `${colSpan}` }, + ...headerLabels.slice(indexWithColspan + colSpan), + ] + const table = mount( +
    + ) + const cells = table.find('td') + const label = headerLabelsWithColspan[indexWithColspan].label + + // all body cells that are group under the single header should share the same label + for (let i = 0; i < colSpan; ++i) { + const index = indexWithColspan + i + const cell = cells.at(index) + expect(cell).toHaveLength(1) + + const title = cell.find('.title') + expect(title.text()).toBe(label) + } + + // the next body cell should have the label of the next header cell + const titleAfterColspan = + headerLabelsWithColspan[indexWithColspan + 1].label + const cellAfterColspan = cells.at(indexWithColspan + colSpan) + expect(cellAfterColspan).toHaveLength(1) + expect(cellAfterColspan.find('.title').text()).toBe(titleAfterColspan) + }) +}) diff --git a/packages/core/src/StackedTable/StackedTableBody.js b/packages/core/src/StackedTable/StackedTableBody.js new file mode 100644 index 0000000000..2eadf1a7af --- /dev/null +++ b/packages/core/src/StackedTable/StackedTableBody.js @@ -0,0 +1,38 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @module + * @param {StackedTableBody.PropTypes} + * @returns {React.Component} + * @example import { StackedTableBody } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/stackedtable--default|Storybook} + */ +export const StackedTableBody = ({ children, className, dataTest }) => ( + + {children} + + +) + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * Should only be StackedTableCell or StackedTableCellHead + * @prop {string} [className] + * @prop {string} [dataTest] + */ +StackedTableBody.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +StackedTableBody.defaultProps = { + dataTest: 'dhis2-uicore-stackedtablebody', +} diff --git a/packages/core/src/StackedTable/StackedTableCell.js b/packages/core/src/StackedTable/StackedTableCell.js new file mode 100644 index 0000000000..e12cc832e6 --- /dev/null +++ b/packages/core/src/StackedTable/StackedTableCell.js @@ -0,0 +1,81 @@ +import React from 'react' +import propTypes from 'prop-types' + +import { colors } from '@dhis2/ui-constants' +import { ContentWithTitle } from './ContentWithTitle.js' + +/** + * @module + * @param {StackedTableCell.PropTypes} + * @returns {React.Component} + * @example import { StackedTableCell } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/stackedtable--default|Storybook} + */ +export const StackedTableCell = ({ + children, + className, + colSpan, + column, + dataTest, + headerLabels, + hideTitle, + rowSpan, + title, +}) => { + const cellTitle = title || headerLabels[column] || '' + const realTitle = hideTitle ? '' : cellTitle + + return ( + + ) +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [colSpan] + * @prop {number} [column] + * @prop {string} [dataTest] + * @prop {boolean} [hideTitle] + * @prop {boolean} [rowSpan] + * @prop {string} [title] + */ +StackedTableCell.propTypes = { + children: propTypes.node, + className: propTypes.string, + colSpan: propTypes.string, + column: propTypes.number, + dataTest: propTypes.string, + headerLabels: propTypes.arrayOf(propTypes.string), + hideTitle: propTypes.bool, + rowSpan: propTypes.string, + title: propTypes.string, +} + +StackedTableCell.defaultProps = { + dataTest: 'dhis2-uicore-stackedtablecell', + headerLabels: [], +} diff --git a/packages/core/src/StackedTable/StackedTableCellHead.js b/packages/core/src/StackedTable/StackedTableCellHead.js new file mode 100644 index 0000000000..637494414b --- /dev/null +++ b/packages/core/src/StackedTable/StackedTableCellHead.js @@ -0,0 +1,61 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors } from '@dhis2/ui-constants' + +/** + * @module + * @param {StackedTableCellHead.PropTypes} + * @returns {React.Component} + * @example import { StackedTableCellHead } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/stackedtable--default|Storybook} + */ +export const StackedTableCellHead = ({ + children, + className, + colSpan, + dataTest, + rowSpan, +}) => ( + +) + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [children] + * Can be left empty to hide titles for all columns + * @prop {string} [className] + * @prop {string} [colSpan] + * @prop {string} [dataTest] + * @prop {string} [rowSpan] + */ +StackedTableCellHead.propTypes = { + children: propTypes.string, + className: propTypes.string, + colSpan: propTypes.string, + dataTest: propTypes.string, + rowSpan: propTypes.string, +} + +StackedTableCellHead.defaultProps = { + children: '', + dataTest: 'dhis2-uicore-stackedtablecellhead', +} diff --git a/packages/core/src/StackedTable/StackedTableFoot.js b/packages/core/src/StackedTable/StackedTableFoot.js new file mode 100644 index 0000000000..c1f22a6ecc --- /dev/null +++ b/packages/core/src/StackedTable/StackedTableFoot.js @@ -0,0 +1,39 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @module + * @param {StackedTableFoot.PropTypes} + * @returns {React.Component} + * @example import { StackedTableFoot } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/stackedtable--default|Storybook} + */ +export const StackedTableFoot = ({ children, className, dataTest }) => ( + + {children} + + +) + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [children] + * Has to be instance of StackedTableRow + * @prop {string} [className] + * @prop {string} [dataTest] + */ +StackedTableFoot.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +StackedTableFoot.defaultProps = { + dataTest: 'dhis2-uicore-stackedtablefoot', +} diff --git a/packages/core/src/StackedTable/StackedTableHead.js b/packages/core/src/StackedTable/StackedTableHead.js new file mode 100644 index 0000000000..c8064c2535 --- /dev/null +++ b/packages/core/src/StackedTable/StackedTableHead.js @@ -0,0 +1,38 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @module + * @param {StackedTableHead.PropTypes} + * @returns {React.Component} + * @example import { StackedTableHead } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/stackedtable--default|Storybook} + */ +export const StackedTableHead = ({ children, className, dataTest }) => ( + + {children} + + +) + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [children] + * Has to be instance of StackedTableRowHead + * @prop {string} [className] + * @prop {string} [dataTest] + */ +StackedTableHead.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +StackedTableHead.defaultProps = { + dataTest: 'dhis2-uicore-stackedtablehead', +} diff --git a/packages/core/src/StackedTable/StackedTableRow.js b/packages/core/src/StackedTable/StackedTableRow.js new file mode 100644 index 0000000000..3f864a14d0 --- /dev/null +++ b/packages/core/src/StackedTable/StackedTableRow.js @@ -0,0 +1,68 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { colors } from '@dhis2/ui-constants' + +import { Consumer } from '../StackedTable/TableContext.js' + +import { addColNumToChildren } from './addColNumToChildren' +import { supplyHeaderLabelsToChildren } from './supplyHeaderLabelsToChildren' + +/** + * @module + * @param {StackedTableRow.PropTypes} + * @returns {React.Component} + * @example import { StackedTableRow } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/stackedtable--default|Storybook} + */ +export const StackedTableRow = ({ children, className, dataTest }) => ( + + + {({ headerLabels }) => + supplyHeaderLabelsToChildren( + headerLabels, + addColNumToChildren(children) + ) + } + + + + +) + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * Has to be instance of StackedTableCell or StackedTableCellHead + * @prop {string} [className] + * @prop {string} [dataTest] + */ +StackedTableRow.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +StackedTableRow.defaultProps = { + dataTest: 'dhis2-uicore-stackedtablerow', +} diff --git a/packages/core/src/StackedTable/StackedTableRowHead.js b/packages/core/src/StackedTable/StackedTableRowHead.js new file mode 100644 index 0000000000..f543374e76 --- /dev/null +++ b/packages/core/src/StackedTable/StackedTableRowHead.js @@ -0,0 +1,35 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { StackedTableRow } from './StackedTableRow.js' + +/** + * @module + * @param {StackedTableRowHead.PropTypes} + * @returns {React.Component} + * @example import { StackedTableRowHead } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/stackedtable--default|Storybook} + */ +export const StackedTableRowHead = ({ children, className, dataTest }) => ( + + {children} + +) + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * Has to be instance of StackedTableCellHead + * @prop {string} [className] + * @prop {string} [dataTest] + */ +StackedTableRowHead.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} + +StackedTableRowHead.defaultProps = { + dataTest: 'dhis2-uicore-stackedtablerowhead', +} diff --git a/packages/core/src/StackedTable/Table.js b/packages/core/src/StackedTable/Table.js new file mode 100644 index 0000000000..d991d136f6 --- /dev/null +++ b/packages/core/src/StackedTable/Table.js @@ -0,0 +1,28 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors } from '@dhis2/ui-constants' + +export const Table = ({ children, className, dataTest }) => ( +
    + {children} + + + + {children &&
    {children}
    } + + +
    + {children} + + +
    +) + +Table.propTypes = { + children: propTypes.node.isRequired, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/StackedTable/TableContext.js b/packages/core/src/StackedTable/TableContext.js new file mode 100644 index 0000000000..bd6ef5bfc9 --- /dev/null +++ b/packages/core/src/StackedTable/TableContext.js @@ -0,0 +1,4 @@ +import { createContext } from 'react' + +export const TableContext = createContext({ headerLabels: [] }) +export const { Provider, Consumer } = TableContext diff --git a/packages/core/src/StackedTable/addColNumToChildren.js b/packages/core/src/StackedTable/addColNumToChildren.js new file mode 100644 index 0000000000..b51bda4d12 --- /dev/null +++ b/packages/core/src/StackedTable/addColNumToChildren.js @@ -0,0 +1,16 @@ +import { Children, cloneElement } from 'react' + +export const addColNumToChildren = children => { + let curCol = 0 + + return Children.map(children, child => { + const column = child.props.column || curCol + const colSpan = child.props.colSpan + ? parseInt(child.props.colSpan, 10) + : 1 + + curCol += colSpan + + return cloneElement(child, { column }) + }) +} diff --git a/packages/core/src/StackedTable/extractHeaderLabels.js b/packages/core/src/StackedTable/extractHeaderLabels.js new file mode 100644 index 0000000000..9687291cc6 --- /dev/null +++ b/packages/core/src/StackedTable/extractHeaderLabels.js @@ -0,0 +1,95 @@ +import React from 'react' +import { StackedTableHead } from './StackedTableHead.js' + +const isChildTableHead = child => child.type === StackedTableHead +const extractChildrenProp = component => component.props.children + +const extractRowsFromTableChildren = children => + React.Children.toArray(children) + .filter(isChildTableHead) + + // extract table head children (rows) + .map(extractChildrenProp) + + // when there are multiple header rows, + // children will come as arrays + .reduce( + (flattened, row) => + Array.isArray(row) + ? [...flattened, ...row] + : [...flattened, row], + [] + ) + + // extract table row children (cells), + // will return an array with arrays of cells + .map(extractChildrenProp) + +const calculateColumnCount = row => + Array.isArray(row) + ? row.reduce( + (total, col) => + // make sure to take col span into account + col.props.colSpan + ? total + parseInt(col.props.colSpan, 10) + : total + 1, + 0 + ) + : 1 + +const mapCellsToLabels = rowChildren => { + let labels = [] + // in case there's only one cell, the children are not an array + const row = Array.isArray(rowChildren) ? rowChildren : [rowChildren] + + // Using a for loop here to be able to increment "i" + // when a cell has a colspan prop by the colspan number + for (let i = 0, count = row.length; i < count; ++i) { + const cell = row[i] + const colSpan = cell.props.colSpan + ? parseInt(cell.props.colSpan, 10) + : 1 + + const label = extractLabelFromCell(cell) + + // Add a label entry for each column + labels = [...labels, ...Array(colSpan).fill(label)] + } + + return labels +} + +const extractLabelFromCell = cell => + !cell.props.hideResponsiveLabel ? cell.props.children : '' + +const combineRowLables = (columnCount, rowCount, headerLabels) => + // create array with length of column count + Array(columnCount) + .fill('') + .reduce((labels, _, colIndex) => { + // an array with all labels for a column + const colLabels = + // create array with length of rows + Array(rowCount) + .fill('') + // get label for current row & col + .map((__, rowIndex) => headerLabels[rowIndex][colIndex]) + // remove empty ones + .filter(val => val) + + return [...labels, colLabels.join(' / ')] + }, []) + +export const extractHeaderLabels = children => { + if (React.Children.count(children) === 0) return [] + + const rows = extractRowsFromTableChildren(children) + + if (!rows.length) return [] + + const rowCount = rows.length + const columnCount = calculateColumnCount(rows[0]) + const headerLabels = rows.map(mapCellsToLabels) + + return combineRowLables(columnCount, rowCount, headerLabels) +} diff --git a/packages/core/src/StackedTable/supplyHeaderLabelsToChildren.js b/packages/core/src/StackedTable/supplyHeaderLabelsToChildren.js new file mode 100644 index 0000000000..f9ea2c0384 --- /dev/null +++ b/packages/core/src/StackedTable/supplyHeaderLabelsToChildren.js @@ -0,0 +1,7 @@ +import { Children, cloneElement } from 'react' + +export const supplyHeaderLabelsToChildren = (headerLabels, children) => { + return Children.map(children, child => { + return cloneElement(child, { headerLabels }) + }) +} diff --git a/packages/core/src/Switch/Switch.js b/packages/core/src/Switch/Switch.js new file mode 100644 index 0000000000..85c36236dd --- /dev/null +++ b/packages/core/src/Switch/Switch.js @@ -0,0 +1,208 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React, { Component, createRef } from 'react' + +import { Regular } from '@dhis2/ui-icons' +import { colors, theme, sharedPropTypes } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * + * @param {Switch.PropTypes} props + * @returns {React.Component} + * + * @example import { Switch } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/switch.md|Design system} + * @see Live demo: {@link /demo/?path=/story/switch--default|Storybook} + */ +class Switch extends Component { + ref = createRef() + + componentDidMount() { + if (this.props.initialFocus) { + this.ref.current.focus() + } + } + + handleChange = e => { + if (this.props.onChange) { + this.props.onChange(this.createHandlerPayload(), e) + } + } + + handleBlur = e => { + if (this.props.onBlur) { + this.props.onBlur(this.createHandlerPayload(), e) + } + } + + handleFocus = e => { + if (this.props.onFocus) { + this.props.onFocus(this.createHandlerPayload(), e) + } + } + + createHandlerPayload() { + return { + value: this.props.value, + name: this.props.name, + checked: !this.props.checked, + } + } + + render() { + const { + checked = false, + className, + disabled, + error, + label, + name, + tabIndex, + valid, + value, + warning, + dense, + dataTest, + } = this.props + + const classes = cx({ + checked, + disabled, + valid, + error, + warning, + dense, + }) + + return ( + + ) + } +} + +Switch.defaultProps = { + dataTest: 'dhis2-uicore-switch', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [value] + * @prop {Node} [label] + * @prop {function} [onChange] - called with the signature `object, event` + * @prop {string} [name] + * @prop {string} [className] + * @prop {string} [tabIndex] + * + * @prop {boolean} [disabled] + * @prop {boolean} [checked] + * @prop {boolean} [initialFocus] + * + * @prop {boolean} [valid] - `valid`, `warning`, and `error` are + * mutually exclusive + * @prop {boolean} [warning] + * @prop {boolean} [error] + * + * @prop {boolean} [dense] + * + * @prop {function} [onFocus] + * @prop {function} [onBlur] + * @prop {string} [dataTest] + */ +Switch.propTypes = { + checked: propTypes.bool, + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + disabled: propTypes.bool, + error: sharedPropTypes.statusPropType, + initialFocus: propTypes.bool, + label: propTypes.node, + name: propTypes.string, + tabIndex: propTypes.string, + valid: sharedPropTypes.statusPropType, + value: propTypes.string, + warning: sharedPropTypes.statusPropType, + onBlur: propTypes.func, + onChange: propTypes.func, + onFocus: propTypes.func, +} + +export { Switch } diff --git a/packages/core/src/Switch/Switch.stories.e2e.js b/packages/core/src/Switch/Switch.stories.e2e.js new file mode 100644 index 0000000000..973f4e8c36 --- /dev/null +++ b/packages/core/src/Switch/Switch.stories.e2e.js @@ -0,0 +1,43 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' +import { Switch } from './Switch.js' + +window.onChange = window.Cypress && window.Cypress.cy.stub() +window.onBlur = window.Cypress && window.Cypress.cy.stub() +window.onFocus = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Switch', module) + .add('With onChange', () => ( + + )) + .add('With initialFocus and onBlur', () => ( + + )) + .add('With onFocus', () => ( + + )) + .add('With disabled', () => ( + + )) + .add('With label', () => ( + + )) + .add('With initialFocus', () => ( + + )) diff --git a/packages/core/src/Switch/Switch.stories.js b/packages/core/src/Switch/Switch.stories.js new file mode 100644 index 0000000000..92e5e7ae86 --- /dev/null +++ b/packages/core/src/Switch/Switch.stories.js @@ -0,0 +1,373 @@ +import { storiesOf } from '@storybook/react' +import React from 'react' + +import { Switch } from './Switch.js' + +window.onChange = (payload, event) => { + console.log('onClick payload', payload) + console.log('onClick event', event) +} + +window.onFocus = (payload, event) => { + console.log('onFocus payload', payload) + console.log('onFocus event', event) +} + +window.onBlur = (payload, event) => { + console.log('onBlur payload', payload) + console.log('onBlur event', event) +} + +const onChange = (...args) => window.onChange(...args) +const onFocus = (...args) => window.onFocus(...args) +const onBlur = (...args) => window.onBlur(...args) + +storiesOf('Component/Core/Switch', module) + // Regular + .add('Default', () => ( + + )) + + .add('Focused unchecked', () => ( + <> + + + + )) + + .add('Focused checked', () => ( + <> + + + + )) + + .add('Checked', () => ( + + )) + + .add('Disabled', () => ( + <> + + + + )) + + .add('Valid', () => ( + <> + + + + )) + + .add('Warning', () => ( + <> + + + + )) + + .add('Error', () => ( + <> + + + + )) + + .add('Image label', () => ( + } + value="with-help" + onChange={onChange} + onFocus={onFocus} + onBlur={onBlur} + /> + )) + + // Dense + .add('Default - Dense', () => ( + + )) + + .add('Focused unchecked - Dense', () => ( + + )) + + .add('Focused checked - Dense', () => ( + + )) + + .add('Checked - Dense', () => ( + + )) + + .add('Disabled - Dense', () => ( + <> + + + + )) + + .add('Valid - Dense', () => ( + <> + + + + )) + + .add('Warning - Dense', () => ( + <> + + + + )) + + .add('Error - Dense', () => ( + <> + + + + )) + + .add('Image label - Dense', () => ( + } + value="with-help" + onChange={onChange} + onFocus={onFocus} + onBlur={onBlur} + /> + )) diff --git a/packages/core/src/Tab/Tab.js b/packages/core/src/Tab/Tab.js new file mode 100644 index 0000000000..ef10459c7d --- /dev/null +++ b/packages/core/src/Tab/Tab.js @@ -0,0 +1,158 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { colors, theme } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Tab.PropTypes} props + * @returns {React.Component} + * + * @example import { Tab } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/tab.md|Design system} + * @see Live demo: {@link /demo/?path=/story/tabs--default-fluid|Storybook} + */ +const Tab = ({ + icon, + onClick, + selected, + disabled, + children, + className, + dataTest, +}) => ( + +) + +Tab.defaultProps = { + dataTest: 'dhis2-uicore-tab', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Element} [icon] + * @prop {function} [onClick] + * @prop {boolean} [selected] + * @prop {boolean} [disabled] + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +Tab.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + disabled: propTypes.bool, + icon: propTypes.element, + selected: propTypes.bool, + onClick: propTypes.func, +} + +export { Tab } diff --git a/packages/core/src/Tab/Tab.stories.e2e.js b/packages/core/src/Tab/Tab.stories.e2e.js new file mode 100644 index 0000000000..b31e8de373 --- /dev/null +++ b/packages/core/src/Tab/Tab.stories.e2e.js @@ -0,0 +1,15 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Tab } from './Tab.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() + +storiesOf('Tab', module) + .add('With onClick', () => Tab A) + .add('With children', () => I am a child) + .add('With icon', () => Icon}>Children) + .add('With onClick and disabled', () => ( + + Tab A + + )) diff --git a/packages/core/src/TabBar/ScrollBar.js b/packages/core/src/TabBar/ScrollBar.js new file mode 100644 index 0000000000..f7c33c1847 --- /dev/null +++ b/packages/core/src/TabBar/ScrollBar.js @@ -0,0 +1,223 @@ +import React, { Component, createRef } from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' +import { ChevronLeft, ChevronRight } from '@dhis2/ui-icons' +import { colors } from '@dhis2/ui-constants' +import { detectHorizontalScrollbarHeight } from './detectHorizontalScrollbarHeight' +import { animatedSideScroll } from './animatedSideScroll' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @private + * @param {ScrollBar.PropTypes} props + * @returns {React.Component} + */ +class ScrollBar extends Component { + scrollBox = createRef() + scrollArea = createRef() + state = { + scrolledToStart: false, + scrolledToEnd: false, + } + horizontalScrollBarHeight = detectHorizontalScrollbarHeight() + + componentDidMount() { + this.scrollSelectedTabIntoView() + this.attachSideScrollListener() + } + + componentWillUnmount() { + this.removeSideScrollListener() + } + + scrollRight = () => this.scroll() + + scrollLeft = () => this.scroll(true) + + scroll(goBackwards) { + this.removeSideScrollListener() + + animatedSideScroll( + this.scrollBox.current, + this.animatedScrollCallback, + goBackwards + ) + } + + animatedScrollCallback = () => { + this.toggleScrollButtonVisibility() + this.attachSideScrollListener() + } + + toggleScrollButtonVisibility = () => { + const { scrollLeft, offsetWidth } = this.scrollBox.current + const { offsetWidth: areaOffsetWidth } = this.scrollArea.current + const scrolledToStart = scrollLeft <= 0 + const scrolledToEnd = scrollLeft + offsetWidth >= areaOffsetWidth + + if ( + this.state.scrolledToStart !== scrolledToStart || + this.state.scrolledToEnd !== scrolledToEnd + ) { + this.setState({ + scrolledToStart, + scrolledToEnd, + }) + } + } + + scrollSelectedTabIntoView() { + const scrollBoxEl = this.scrollBox.current + const tab = scrollBoxEl.querySelector('.tab.selected') + + if (tab) { + const tabEnd = tab.offsetLeft + tab.offsetWidth + + if (tabEnd > scrollBoxEl.offsetWidth) { + scrollBoxEl.scrollLeft = tabEnd - scrollBoxEl.offsetWidth + } + } + } + + attachSideScrollListener() { + this.scrollBox.current.addEventListener( + 'scroll', + this.toggleScrollButtonVisibility + ) + } + + removeSideScrollListener() { + this.scrollBox.current.removeEventListener( + 'scroll', + this.toggleScrollButtonVisibility + ) + } + + render() { + const { scrolledToStart, scrolledToEnd } = this.state + const { children, className, dataTest } = this.props + + return ( +
    + +
    +
    +
    + {children} +
    +
    +
    + + + + + +
    + ) + } +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} children + * @prop {string} [className] + */ +ScrollBar.propTypes = { + children: propTypes.node.isRequired, + dataTest: propTypes.string.isRequired, + className: propTypes.string, +} + +export { ScrollBar } diff --git a/packages/core/src/TabBar/TabBar.js b/packages/core/src/TabBar/TabBar.js new file mode 100644 index 0000000000..4e34eeaef6 --- /dev/null +++ b/packages/core/src/TabBar/TabBar.js @@ -0,0 +1,62 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { ScrollBar } from './ScrollBar.js' +import { Tabs } from './Tabs.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {TabBar.PropTypes} props + * @returns {React.Component} + * + * @example import { TabBar } from '@dhis2/ui-core' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/tab.md|Design system} + * @see Live demo: {@link /demo/?path=/story/tabs--default-fluid|Storybook} + */ +const TabBar = ({ fixed, children, className, scrollable, dataTest }) => { + if (scrollable) { + return ( +
    + + + {children} + + +
    + ) + } + + return ( +
    + + {children} + +
    + ) +} + +TabBar.defaultProps = { + dataTest: 'dhis2-uicore-tabbar', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Tab|Array.} [children] + * @prop {string} [className] + * @prop {boolean} [fixed] + * @prop {boolean} [scrollable] + * @prop {string} [dataTest] + * @prop {string} [dataTest] + */ +TabBar.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + fixed: propTypes.bool, + scrollable: propTypes.bool, +} + +export { TabBar } diff --git a/packages/core/src/TabBar/TabBar.stories.e2e.js b/packages/core/src/TabBar/TabBar.stories.e2e.js new file mode 100644 index 0000000000..c9e2630182 --- /dev/null +++ b/packages/core/src/TabBar/TabBar.stories.e2e.js @@ -0,0 +1,10 @@ +import React from 'react' + +import { storiesOf } from '@storybook/react' +import { TabBar } from './TabBar.js' + +storiesOf('TabBar', module) + .add('With children', () => I am a child) + .add('Scrollable with children', () => ( + I am a child + )) diff --git a/packages/core/src/TabBar/TabBar.stories.js b/packages/core/src/TabBar/TabBar.stories.js new file mode 100644 index 0000000000..3d5d77e873 --- /dev/null +++ b/packages/core/src/TabBar/TabBar.stories.js @@ -0,0 +1,108 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' + +import { AttachFile } from '@dhis2/ui-icons' + +import { Tab } from '../index.js' +import { TabBar } from './TabBar.js' + +const Wrapper = fn => ( +
    + {fn()} +

    Max-width of this container is 700 px

    +
    +) + +window.onClick = (payload, event) => { + console.log('onClick payload', payload) + console.log('onClick event', event) +} + +const onClick = (...args) => window.onClick(...args) + +storiesOf('Component/Core/TabBar', module) + .addDecorator(Wrapper) + .add('Default (fluid)', () => ( + + Tab A + Tab B + + Tab C + + Tab D + Tab E + Tab F + Tab G + + )) + .add('Fixed - tabs fill content', () => ( + + Tab A + Tab B + + Tab C + + Tab D + Tab E + Tab F + Tab G + + )) + .add('Tabs with scroller', () => ( + + Tab A + Tab B + Tab C + Tab D + Tab E + Tab F + Tab G + Tab H + Tab I + Tab J + Tab K + Tab L + + Tab M + + Tab N + Tab O + Tab P + Tab Q + Tab R + + )) + .add('Tab states', () => ( + + Default + + Selected + + Disabled + + Text overflow - This tab has a very long text and it exceeds the + maximum width of 320px + + + )) + .add('Tab states - with icon', () => ( + + }> + Default + + } selected> + Selected + + } disabled> + Disabled + + }> + Text overflow - This tab has a very long text and it exceeds the + maximum width of 320px + + + )) diff --git a/packages/core/src/TabBar/Tabs.js b/packages/core/src/TabBar/Tabs.js new file mode 100644 index 0000000000..c1a103f653 --- /dev/null +++ b/packages/core/src/TabBar/Tabs.js @@ -0,0 +1,45 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { colors } from '@dhis2/ui-constants' + +/** + * @module + * @private + * @param {Object} PropTypes + * @returns {React.Component} + */ +const Tabs = ({ children, fixed, dataTest }) => ( +
    + {children} + + +
    +) + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} children + * @prop {boolean} [fixed] + * @prop {string} [dataTest] + */ +Tabs.propTypes = { + dataTest: propTypes.string.isRequired, + children: propTypes.node, + fixed: propTypes.bool, +} + +export { Tabs } diff --git a/packages/core/src/TabBar/animatedSideScroll.js b/packages/core/src/TabBar/animatedSideScroll.js new file mode 100644 index 0000000000..d9e38efcd2 --- /dev/null +++ b/packages/core/src/TabBar/animatedSideScroll.js @@ -0,0 +1,63 @@ +const DURATION = 250 +const SCROLL_STEP = 0.5 + +export function animatedSideScroll(scrollBox, callback, goBackwards = false) { + const startValue = scrollBox.scrollLeft + const endValue = getEndValue(scrollBox, startValue, goBackwards) + const change = endValue - startValue + const step = createFrameStepper({ + scrollBox, + callback, + startValue, + endValue, + change, + }) + + window.requestAnimationFrame(step) +} + +function getEndValue(scrollBox, startValue, goBackwards) { + const scrollDistance = scrollBox.clientWidth * SCROLL_STEP + const inverter = goBackwards ? -1 : 1 + return Math.floor(startValue + scrollDistance * inverter) +} + +function createFrameStepper({ + scrollBox, + callback, + startValue, + endValue, + change, +}) { + let startTimestamp, elapsedTime, scrollValue + + return function step(timestamp) { + if (!startTimestamp) { + startTimestamp = timestamp + } + + elapsedTime = timestamp - startTimestamp + scrollValue = easeInOutQuad({ + currentTime: elapsedTime, + DURATION, + startValue, + change, + }) + + if (elapsedTime >= DURATION) { + if (scrollValue !== endValue) { + scrollBox.scrollLeft = endValue + } + callback && callback() + } else { + scrollBox.scrollLeft = scrollValue + window.requestAnimationFrame(step) + } + } +} + +function easeInOutQuad({ currentTime, startValue, change }) { + return (currentTime /= DURATION / 2) < 1 + ? (change / 2) * currentTime * currentTime + startValue + : (-change / 2) * (--currentTime * (currentTime - 2) - 1) + startValue +} diff --git a/packages/core/src/TabBar/detectHorizontalScrollbarHeight.js b/packages/core/src/TabBar/detectHorizontalScrollbarHeight.js new file mode 100644 index 0000000000..1ddb266ef3 --- /dev/null +++ b/packages/core/src/TabBar/detectHorizontalScrollbarHeight.js @@ -0,0 +1,36 @@ +let horizontalScrollbarHeight +const className = '__vertical-scrollbar-height-test__' +const styles = ` + .${className} { + position: absolute; + top: -9999px; + width: 100px; + height: 100px; + overflow-x: scroll; + } + .${className}::-webkit-scrollbar { + display: none; + } +` + +export function detectHorizontalScrollbarHeight() { + if (horizontalScrollbarHeight) { + return horizontalScrollbarHeight + } + + const style = document.createElement('style') + style.innerHTML = styles + + const el = document.createElement('div') + el.classList.add(className) + + document.body.appendChild(style) + document.body.appendChild(el) + + horizontalScrollbarHeight = el.offsetHeight - el.clientHeight + + document.body.removeChild(style) + document.body.removeChild(el) + + return horizontalScrollbarHeight +} diff --git a/packages/core/src/Table/Table.js b/packages/core/src/Table/Table.js new file mode 100644 index 0000000000..dea9c4f083 --- /dev/null +++ b/packages/core/src/Table/Table.js @@ -0,0 +1,46 @@ +import React from 'react' +import css from 'styled-jsx/css' +import propTypes from '@dhis2/prop-types' + +const tableStyles = css` + table { + border: 1px solid #e8edf2; + background-color: #ffffff; + min-width: 100%; + text-align: left; + border-collapse: collapse; + vertical-align: top; + } +` + +/** + * @module + * @param {Table.PropTypes} props + * @returns {React.Component} + * @example import { Table } from '@dhis2/ui-core' + * @see Live demo: {@link /demo/?path=/story/table--static-layout|Storybook} + */ +export const Table = ({ children, className, dataTest }) => ( + + {children} + + +
    +) + +Table.defaultProps = { + dataTest: 'dhis2-uicore-table', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {TableHead|TableBody|TableFoot|Array.} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +Table.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/Table/Table.stories.js b/packages/core/src/Table/Table.stories.js new file mode 100644 index 0000000000..386186ee5b --- /dev/null +++ b/packages/core/src/Table/Table.stories.js @@ -0,0 +1,304 @@ +import React from 'react' + +import { storiesOf } from '@storybook/react' + +import { Button } from '../index.js' + +import { Table } from './Table.js' +import { TableBody } from './TableBody.js' +import { TableCell } from './TableCell.js' +import { TableCellHead } from './TableCellHead.js' +import { TableFoot } from './TableFoot.js' +import { TableHead } from './TableHead.js' +import { TableRow } from './TableRow.js' +import { TableRowHead } from './TableRowHead.js' + +const TableFooterButton = () => ( +
    + + + +
    +) + +const TableButton = () => + +storiesOf('Component/Core/Table', module).add('Static layout', () => ( + + + + First name + Last name + Incident date + Last updated + Age + Registering unit + Assigned user + Status + + + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + Incomplete + + + Kwasi + Okafor + 08/11/2010 + 02/26/1991 + 38 + Mokassie MCHP + Dashonte Clarke + Complete + + + Siyabonga + Abiodun + 07/21/1981 + 02/06/2007 + 98 + Bathurst MCHP + Unassigned + Incomplete + + + Chiyembekezo + Okeke + 01/23/1982 + 07/15/2003 + 2 + Mayolla MCHP + Wan Gengxin + Incomplete + + + Mtendere + Afolayan + 08/12/1994 + 05/12/1972 + 37 + Gbangadu MCHP + Gvozden Boskovsky + Complete + + + Inyene + Okonkwo + 04/01/1971 + 03/16/2000 + 70 + Kunike Barina + Oscar de la Cavallería + Complete + + + Amaka + Pretorius + 01/25/1996 + 09/15/1986 + 32 + Bargbo + Alberto Raya + Incomplete + + + Meti + Abiodun + 10/24/2010 + 07/26/1989 + 8 + Majihun MCHP + Unassigned + Complete + + + Eshe + Okeke + 01/31/1995 + 01/31/1995 + 63 + Mambiama CHP + Shadrias Pearson + Incomplete + + + Obi + Okafor + 06/07/1990 + 01/03/2006 + 28 + Dalakuru CHP + Anatoliy Shcherbatykh + Incomplete + + + + + + + + + +
    +)) + +storiesOf('Component/Core/Table', module).add( + 'Static layout with buttons in dense cells', + () => ( + + + + First name + Last name + Incident date + Last updated + Age + Registering unit + Assigned user + Button + + + + + Onyekachukwu + Kariuki + 02/06/2007 + 05/25/1972 + 66 + Jawi + Sofie Hubert + + + + + + Kwasi + Okafor + 08/11/2010 + 02/26/1991 + 38 + Mokassie MCHP + Dashonte Clarke + + + + + + Siyabonga + Abiodun + 07/21/1981 + 02/06/2007 + 98 + Bathurst MCHP + Unassigned + + + + + + Chiyembekezo + Okeke + 01/23/1982 + 07/15/2003 + 2 + Mayolla MCHP + Wan Gengxin + + + + + + Mtendere + Afolayan + 08/12/1994 + 05/12/1972 + 37 + Gbangadu MCHP + Gvozden Boskovsky + + + + + + Inyene + Okonkwo + 04/01/1971 + 03/16/2000 + 70 + Kunike Barina + Oscar de la Cavallería + + + + + + Amaka + Pretorius + 01/25/1996 + 09/15/1986 + 32 + Bargbo + Alberto Raya + + + + + + Meti + Abiodun + 10/24/2010 + 07/26/1989 + 8 + Majihun MCHP + Unassigned + + + + + + Eshe + Okeke + 01/31/1995 + 01/31/1995 + 63 + Mambiama CHP + Shadrias Pearson + + + + + + Obi + Okafor + 06/07/1990 + 01/03/2006 + 28 + Dalakuru CHP + Anatoliy Shcherbatykh + + + + + + + + + + + + +
    + ) +) diff --git a/packages/core/src/Table/TableBody.js b/packages/core/src/Table/TableBody.js new file mode 100644 index 0000000000..536a1a725d --- /dev/null +++ b/packages/core/src/Table/TableBody.js @@ -0,0 +1,32 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @module + * @param {TableBody.PropTypes} props + * @returns {React.Component} + * @example import { TableBody } from '@dhis2/ui-core' + * @see Live demo: {@link /demo/?path=/story/table--static-layout|Storybook} + */ +export const TableBody = ({ children, className, dataTest }) => ( + + {children} + +) + +TableBody.defaultProps = { + dataTest: 'dhis2-uicore-tablebody', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {TableRow|Array.} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +TableBody.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/Table/TableCell.js b/packages/core/src/Table/TableCell.js new file mode 100644 index 0000000000..41ee43e772 --- /dev/null +++ b/packages/core/src/Table/TableCell.js @@ -0,0 +1,69 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import css from 'styled-jsx/css' +import cx from 'classnames' + +const tableCellStyles = css` + td { + border-bottom: 1px solid #e8edf2; + font-size: 14px; + line-height: 18px; + padding: 13px 12px; + height: 45px; + } + + .dense { + padding: 9px 12px; + height: 36px; + } +` + +/** + * @module + * @param {TableCell.PropTypes} props + * @returns {React.Component} + * @example import { TableCell } from '@dhis2/ui-core' + * @see Live demo: {@link /demo/?path=/story/table--static-layout|Storybook} + */ +export const TableCell = ({ + className, + children, + colSpan, + rowSpan, + dense, + dataTest, +}) => ( + + {children} + + + +) + +TableCell.defaultProps = { + dataTest: 'dhis2-uicore-tablecell', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [colSpan] + * @prop {string} [rowSpan] + * @prop {bool} [dense] + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +TableCell.propTypes = { + children: propTypes.node, + className: propTypes.string, + colSpan: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + rowSpan: propTypes.string, +} diff --git a/packages/core/src/Table/TableCellHead.js b/packages/core/src/Table/TableCellHead.js new file mode 100644 index 0000000000..18161cd831 --- /dev/null +++ b/packages/core/src/Table/TableCellHead.js @@ -0,0 +1,69 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import css from 'styled-jsx/css' +import cx from 'classnames' + +const tableCellHeadStyles = css` + th { + border-bottom: 1px solid #e8edf2; + font-size: 14px; + line-height: 18px; + padding: 13px 12px; + height: 45px; + } + + .dense { + padding: 9px 12px; + height: 36px; + } +` + +/** + * @module + * @param {TableCellHead.PropTypes} props + * @returns {React.Component} + * @example import { TableCellHead } from '@dhis2/ui-core' + * @see Live demo: {@link /demo/?path=/story/table--static-layout|Storybook} + */ +export const TableCellHead = ({ + colSpan, + rowSpan, + dense, + children, + className, + dataTest, +}) => ( + + {children} + + + +) + +TableCellHead.defaultProps = { + dataTest: 'dhis2-uicore-tablecellhead', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [colSpan] + * @prop {string} [rowSpan] + * @prop {bool} [dense] + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +TableCellHead.propTypes = { + children: propTypes.node, + className: propTypes.string, + colSpan: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + rowSpan: propTypes.string, +} diff --git a/packages/core/src/Table/TableFoot.js b/packages/core/src/Table/TableFoot.js new file mode 100644 index 0000000000..8a0f71f2be --- /dev/null +++ b/packages/core/src/Table/TableFoot.js @@ -0,0 +1,32 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @module + * @param {TableFoot.PropTypes} props + * @returns {React.Component} + * @example import { TableFoot } from '@dhis2/ui-core' + * @see Live demo: {@link /demo/?path=/story/table--static-layout|Storybook} + */ +export const TableFoot = ({ children, className, dataTest }) => ( + + {children} + +) + +TableFoot.defaultProps = { + dataTest: 'dhis2-uicore-tablefoot', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {TableRow|Array.} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +TableFoot.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/Table/TableHead.js b/packages/core/src/Table/TableHead.js new file mode 100644 index 0000000000..1beba8443e --- /dev/null +++ b/packages/core/src/Table/TableHead.js @@ -0,0 +1,32 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @module + * @param {TableHead.PropTypes} props + * @returns {React.Component} + * @example import { TableHead } from '@dhis2/ui-core' + * @see Live demo: {@link /demo/?path=/story/table--static-layout|Storybook} + */ +export const TableHead = ({ children, className, dataTest }) => ( + + {children} + +) + +TableHead.defaultProps = { + dataTest: 'dhis2-uicore-tablehead', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {TableRowHead|Array.} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +TableHead.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/Table/TableRow.js b/packages/core/src/Table/TableRow.js new file mode 100644 index 0000000000..f617f2199f --- /dev/null +++ b/packages/core/src/Table/TableRow.js @@ -0,0 +1,41 @@ +import React from 'react' +import css from 'styled-jsx/css' +import propTypes from '@dhis2/prop-types' + +const tableRowStyles = css` + tr:nth-child(even) { + background: #fbfcfd; + } +` + +/** + * @module + * @param {TableRow.PropTypes} props + * @returns {React.Component} + * @example import { TableRow } from '@dhis2/ui-core' + * @see Live demo: {@link /demo/?path=/story/table--static-layout|Storybook} + */ +export const TableRow = ({ children, className, dataTest }) => ( + + {children} + + + +) + +TableRow.defaultProps = { + dataTest: 'dhis2-uicore-tablerow', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {TableCell|TableCellHead|Array.} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +TableRow.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/Table/TableRowHead.js b/packages/core/src/Table/TableRowHead.js new file mode 100644 index 0000000000..b1a6e37526 --- /dev/null +++ b/packages/core/src/Table/TableRowHead.js @@ -0,0 +1,35 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' + +import { TableRow } from './TableRow.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {TableRowHead.PropTypes} props + * @returns {React.Component} + * @example import { TableRowHead } from '@dhis2/ui-core' + * @see Live demo: {@link /demo/?path=/story/table--static-layout|Storybook} + */ +export const TableRowHead = ({ children, className, dataTest }) => ( + + {children} + +) + +TableRowHead.defaultProps = { + dataTest: 'dhis2-uicore-tablerowhead', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {TableCellHead|Array.} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + */ +TableRowHead.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, +} diff --git a/packages/core/src/Tag/Tag.js b/packages/core/src/Tag/Tag.js new file mode 100644 index 0000000000..da4ad2979d --- /dev/null +++ b/packages/core/src/Tag/Tag.js @@ -0,0 +1,133 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { TagIcon } from './TagIcon.js' +import { TagText } from './TagText.js' +import { colors } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {Tag.PropTypes} props + * @returns {React.Component} + * @example import { Tag } from @dhis2/ui-core + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/atoms/tag.md|Design system} + * @see Live demo: {@link /demo/?path=/story/tag--default|Storybook} + */ +export const Tag = ({ + neutral, + negative, + positive, + icon, + bold, + className, + dataTest, + children, +}) => ( +
    + {icon && {icon}} + {children} + +
    +) + +const tagVariantPropType = propTypes.mutuallyExclusive( + ['neutral', 'positive', 'negative'], + propTypes.bool +) + +Tag.defaultProps = { + dataTest: 'dhis2-uicore-tag', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {boolean} [bold] + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest] + * @prop {Node} [icon] + * @prop {boolean} [neutral] - `neutral`, `positive`, and + * `negative` are mutually exclusive boolean props + * @prop {boolean} [positive] + * @prop {boolean} [negative] + */ + +Tag.propTypes = { + bold: propTypes.bool, + children: propTypes.string, + className: propTypes.string, + dataTest: propTypes.string, + icon: propTypes.node, + negative: tagVariantPropType, + neutral: tagVariantPropType, + positive: tagVariantPropType, +} diff --git a/packages/core/src/Tag/Tag.stories.e2e.js b/packages/core/src/Tag/Tag.stories.e2e.js new file mode 100644 index 0000000000..657f393b6b --- /dev/null +++ b/packages/core/src/Tag/Tag.stories.e2e.js @@ -0,0 +1,8 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Tag } from './Tag.js' + +storiesOf('Tag', module) + .add('Without icon', () => Default) + .add('With icon', () => Icon}>Default) + .add('With text', () => Text content) diff --git a/packages/core/src/Tag/Tag.stories.js b/packages/core/src/Tag/Tag.stories.js new file mode 100644 index 0000000000..3a5dfea46b --- /dev/null +++ b/packages/core/src/Tag/Tag.stories.js @@ -0,0 +1,58 @@ +import React from 'react' + +import { Tag } from './Tag.js' + +export default { title: 'Component/Core/Tag', component: Tag } + +export const Default = () => Dog + +export const WithIcon = () => }>Dog + +export const Neutral = () => Dog + +export const Positive = () => Dog + +export const Negative = () => Dog + +export const Bold = () => Dog + +export const WithClippedOversizedIcon = () => ( + }>Dog +) + +export const WithClippedLongText = () => ( + }> + I am long text, therefore I get clipped before I finish + +) + +const ExampleIcon = () => ( + + + + + + + + + +) + +const ExampleLargeIcon = () => ( + + + +) diff --git a/packages/core/src/Tag/TagIcon.js b/packages/core/src/Tag/TagIcon.js new file mode 100644 index 0000000000..ebed83c945 --- /dev/null +++ b/packages/core/src/Tag/TagIcon.js @@ -0,0 +1,20 @@ +import propTypes from '@dhis2/prop-types' +import React from 'react' + +export const TagIcon = ({ children, dataTest }) => ( +
    + {children} + +
    +) + +TagIcon.propTypes = { + dataTest: propTypes.string.isRequired, + children: propTypes.node, +} diff --git a/packages/core/src/Tag/TagText.js b/packages/core/src/Tag/TagText.js new file mode 100644 index 0000000000..72ef215e29 --- /dev/null +++ b/packages/core/src/Tag/TagText.js @@ -0,0 +1,18 @@ +import propTypes from '@dhis2/prop-types' +import React from 'react' + +export const TagText = ({ children, dataTest }) => ( + + {children} + + +) + +TagText.propTypes = { + dataTest: propTypes.string.isRequired, + children: propTypes.node, +} diff --git a/packages/core/src/TextArea/TextArea.js b/packages/core/src/TextArea/TextArea.js new file mode 100644 index 0000000000..55cca1bea3 --- /dev/null +++ b/packages/core/src/TextArea/TextArea.js @@ -0,0 +1,242 @@ +import propTypes from '@dhis2/prop-types' +import React, { Component } from 'react' +import cx from 'classnames' + +import { sharedPropTypes } from '@dhis2/ui-constants' +import { StatusIcon } from '@dhis2/ui-icons' + +import { styles } from './TextArea.styles.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {TextArea.PropTypes} props + * @returns {React.Component} + * + * @example import { TextArea } from '@dhis2/ui-core' + */ +export class TextArea extends Component { + textareaRef = React.createRef() + state = { + height: 'auto', + } + textareaDimensions = { width: 0, height: 0 } + userHasResized = false + + componentDidMount() { + this.attachResizeListener() + + if (this.props.initialFocus) { + this.textareaRef.current.focus() + } + + if (this.shouldDoAutoGrow()) { + this.setHeight() + } + } + + componentDidUpdate(prevProps) { + if (this.shouldDoAutoGrow() && this.props.value !== prevProps.value) { + this.setHeight() + } + } + + attachResizeListener() { + const textarea = this.textareaRef.current + textarea.addEventListener('mousedown', this.setTextareaDimensions) + textarea.addEventListener('mouseup', this.hasUserResized) + } + + removeResizeListener() { + const textarea = this.textareaRef.current + textarea.removeEventListener('mousedown', this.setTextareaDimensions) + textarea.removeEventListener('mouseup', this.hasUserResized) + } + + setHeight() { + const textarea = this.textareaRef.current + const offset = textarea.offsetHeight - textarea.clientHeight + const height = textarea.scrollHeight + offset + 'px' + this.setState({ height }) + } + + setTextareaDimensions = () => { + const textarea = this.textareaRef.current + this.textareaDimensions = { + width: textarea.clientWidth, + height: textarea.clientHeight, + } + } + + shouldDoAutoGrow() { + return this.props.autoGrow && !this.userHasResized + } + + hasUserResized = () => { + const { width: oldWidth, height: oldHeight } = this.textareaDimensions + + this.setTextareaDimensions() + + const { width: newWidth, height: newHeight } = this.textareaDimensions + const userHasResized = newWidth !== oldWidth || newHeight !== oldHeight + + if (userHasResized) { + this.userHasResized = true + this.removeResizeListener() + } + } + + handleChange = e => { + if (this.props.onChange) { + this.props.onChange(this.createHandlerPayload(e), e) + } + } + + handleBlur = e => { + if (this.props.onBlur) { + this.props.onBlur(this.createHandlerPayload(e), e) + } + } + + handleFocus = e => { + if (this.props.onFocus) { + this.props.onFocus(this.createHandlerPayload(e), e) + } + } + + createHandlerPayload(e) { + return { + value: e.target.value, + name: this.props.name, + } + } + + render() { + const { + className, + dense, + disabled, + readOnly, + placeholder, + name, + valid, + error, + warning, + loading, + value, + tabIndex, + rows, + width, + resize, + dataTest, + } = this.props + const { height } = this.state + + return ( +
    +