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..21262b5a35 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +**/cypress/assets +**/src/locales/* 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..5f888976c3 --- /dev/null +++ b/.github/workflows/cypress.yml @@ -0,0 +1,35 @@ +# For more options: +# https://github.com/cypress-io/github-action + +name: 'dhis2: test (cypress)' + +on: push + +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@v2 + + - 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..47ef9d915f --- /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@v2 + - 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..eff6d53db9 --- /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@v2 + - 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..2edbae5657 --- /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@v2 + - 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..b8e62e2ab3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# 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/ +packages/forms/i18n/ 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..3636fdfdb8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,289 @@ +# [5.0.0-alpha.21](https://github.com/dhis2/ui/compare/v5.0.0-alpha.20...v5.0.0-alpha.21) (2020-05-28) + + +### Bug Fixes + +* correctly select and use user locale and application title text ([1c9d055](https://github.com/dhis2/ui/commit/1c9d055e735f6e32706b91e4f75d8be6e6096860)) +* escape regex special chars in search ([2f11c96](https://github.com/dhis2/ui/commit/2f11c96d27398a612ce80beeb52046255d4f8776)) + +# [5.0.0-alpha.20](https://github.com/dhis2/ui/compare/v5.0.0-alpha.19...v5.0.0-alpha.20) (2020-05-28) + + +### Code Refactoring + +* **transfer:** align with select & monorepo structure ([c15477d](https://github.com/dhis2/ui/commit/c15477dcaa582fc059dab35adfc24a19527b7f0c)), closes [#50](https://github.com/dhis2/ui/issues/50) +* **transfer:** align with select & monorepo structure ([d50f960](https://github.com/dhis2/ui/commit/d50f960a675941d8052bae473003895c837a737a)) + + +### BREAKING CHANGES + +* **transfer:** The Transfer component now expects options to be passed in as objects, not as children. Custom components can be provided via the optionComponent prop for all options or via the component property on an individual option. +* **transfer:** The Transfer component now expects strings as selected +values instead of option objects. +* **transfer:** The Transfer component is now part of widgets +* **transfer:** The Transfer component now expects strings as selected +values instead of option objects. +* **transfer:** The Transfer component is now part of `widgets` + +# [5.0.0-alpha.19](https://github.com/dhis2/ui/compare/v5.0.0-alpha.18...v5.0.0-alpha.19) (2020-05-26) + + +### Bug Fixes + +* **popover:** fix arrow rotation ([880395b](https://github.com/dhis2/ui/commit/880395b7631a62281b66aced1a7044d70ed6c878)) +* fix improper merge resolution ([0daeffb](https://github.com/dhis2/ui/commit/0daeffb88f0e3791352f25945f442a0d6b3624e6)) + + +### Code Refactoring + +* rename popover prop ([f7d5c20](https://github.com/dhis2/ui/commit/f7d5c20dd111789fa7230aa0f84eeaf0487df4be)) + + +### BREAKING CHANGES + +* The Popover's `onBackdropClick` prop has been renamed to `onClickOutside` + +# [5.0.0-alpha.18](https://github.com/dhis2/ui/compare/v5.0.0-alpha.17...v5.0.0-alpha.18) (2020-05-26) + + +### Code Refactoring + +* make menu click-based and reorganise related components ([a8b26a0](https://github.com/dhis2/ui/commit/a8b26a0984f892ec9ea61a2c40bb6226e66903a9)) + + +### BREAKING CHANGES + +* Fully overhauled Menu and related components: +- MenuList was renamed to Menu +- Menu was renamed to FlyoutMenu +- The sub-menus now open on click instead of hover +- We have introduced a dedicated `MenuDivider` and `MenuSectionHeader` +- To create sub-menus, you can now add MenuItems directly under a parent MenuItem, no need to wrap them in a Menu/FlyoutMenu anymore + +# [5.0.0-alpha.17](https://github.com/dhis2/ui/compare/v5.0.0-alpha.16...v5.0.0-alpha.17) (2020-05-20) + + +### Bug Fixes + +* **select:** debounce menu width measurement ([0e17c59](https://github.com/dhis2/ui/commit/0e17c598e1e82d40c5145e5135007bffe507200f)) + +# [5.0.0-alpha.16](https://github.com/dhis2/ui/compare/v5.0.0-alpha.15...v5.0.0-alpha.16) (2020-05-20) + + +### Bug Fixes + +* **widgets:** add translated default texts ([c85342d](https://github.com/dhis2/ui/commit/c85342df741a960cd44a8f53a9a250be9e56d7f1)) + +# [5.0.0-alpha.15](https://github.com/dhis2/ui/compare/v5.0.0-alpha.14...v5.0.0-alpha.15) (2020-05-19) + + +### Features + +* **constants:** export constants as well ([51c2eb0](https://github.com/dhis2/ui/commit/51c2eb0b62fdb222708b8a08c13d1aab0192a2b6)) + +# [5.0.0-alpha.14](https://github.com/dhis2/ui/compare/v5.0.0-alpha.13...v5.0.0-alpha.14) (2020-04-30) + + +### Bug Fixes + +* **prop-types:** add missing dhis2 prop-types for ui-icons ([c207524](https://github.com/dhis2/ui/commit/c2075240b70c39ccc380d2817b387911a29cebc5)) + +# [5.0.0-alpha.13](https://github.com/dhis2/ui/compare/v5.0.0-alpha.12...v5.0.0-alpha.13) (2020-04-30) + + +### Bug Fixes + +* **noticebox:** add missing export ([ec2a739](https://github.com/dhis2/ui/commit/ec2a739d19215196b148459f1b00d6d627aa1d10)) + +# [5.0.0-alpha.12](https://github.com/dhis2/ui/compare/v5.0.0-alpha.11...v5.0.0-alpha.12) (2020-04-30) + + +### Bug Fixes + +* **field:** fix prop-type warning ([d55d049](https://github.com/dhis2/ui/commit/d55d0495e20590820e331d251642e528cea30add)) + +# [5.0.0-alpha.11](https://github.com/dhis2/ui/compare/v5.0.0-alpha.10...v5.0.0-alpha.11) (2020-04-29) + + +### Bug Fixes + +* **core:** no top margin if no label for field ([a2d0bad](https://github.com/dhis2/ui/commit/a2d0badea9e384d9047dc2f05a2a0705249a4561)) + + +### Code Refactoring + +* **core:** move fields to widgets ([3b763fa](https://github.com/dhis2/ui/commit/3b763fa8b3f1d36b8090cf037dd2d4135ca1d56c)) +* **core:** reimplement Field ([fbdafb8](https://github.com/dhis2/ui/commit/fbdafb800eb2033ad9b673350243bdc0d49a8f02)) +* move to more explicit final-form api ([a76da00](https://github.com/dhis2/ui/commit/a76da00bffed6e1a7fce2a6a29505ae58ea0db52)) + + +### BREAKING CHANGES + +* **core:** Relocate all *Field components to @dhis2/ui-widgets. +They can be accessed from `@dhis2/ui` using named exports. +* **core:** Field has been reimplemented to compose a field +control, it now adds the Label, Help, Validation components instead of +being a simple div wrapper, which allows us to avoid the code +duplication in each *Field component. +* **core:** ToggleGroup has been removed. Use a FieldSet for +grouping form controls. + +BREKING CHANGE: ToggleGroupField has been renamed to FieldSetField, +which adds the necessary Label, Help, and Validation components to an +entire group of components. +* Field now provides a composition to provide all +necessary things for a *Field component. +* RadioGroup has been deleted. +* CheckboxGroup has been deleted. +* CheckboxGroupControl has been deleted. +* RadioGroupControl has been deleted. + +# [5.0.0-alpha.10](https://github.com/dhis2/ui/compare/v5.0.0-alpha.9...v5.0.0-alpha.10) (2020-04-23) + + +### Code Refactoring + +* **forms:** namespace final-form and react-final-form re-exports ([c59e0bb](https://github.com/dhis2/ui/commit/c59e0bb50ddb82a6c79589727a15483b98b45261)) + + +### BREAKING CHANGES + +* **forms:** final-form and react-final-form exports are now re-exported under the named exports FinalForm and ReactFinalForm respectively. + +# [5.0.0-alpha.9](https://github.com/dhis2/ui/compare/v5.0.0-alpha.8...v5.0.0-alpha.9) (2020-04-23) + + +### Bug Fixes + +* display submit errors ([9f74e89](https://github.com/dhis2/ui/commit/9f74e897f9ee60ae121b05e1442ff356aff9468a)) + +# [5.0.0-alpha.8](https://github.com/dhis2/ui/compare/v5.0.0-alpha.7...v5.0.0-alpha.8) (2020-04-22) + + +### Bug Fixes + +* update number range validation error to match actual bounds ([646f782](https://github.com/dhis2/ui/commit/646f782e7dbbc6bb2da4340d76ce55c7d5ab25db)) + +# [5.0.0-alpha.7](https://github.com/dhis2/ui/compare/v5.0.0-alpha.6...v5.0.0-alpha.7) (2020-04-22) + + +### Bug Fixes + +* update final-form to fix setstate warning ([1bc62b9](https://github.com/dhis2/ui/commit/1bc62b9dc0d2215fc60f3cdacd2304f7a09730d3)) + +# [5.0.0-alpha.6](https://github.com/dhis2/ui/compare/v5.0.0-alpha.5...v5.0.0-alpha.6) (2020-04-20) + + +### Bug Fixes + +* **icons:** add missing icon file ([de0a157](https://github.com/dhis2/ui/commit/de0a1578ad70d127a184868f54d53723acedd29c)) + +# [5.0.0-alpha.5](https://github.com/dhis2/ui/compare/v5.0.0-alpha.4...v5.0.0-alpha.5) (2020-04-16) + +### Code Refactoring + +* layers and overlay components ([24ead4c](https://github.com/dhis2/ui/commit/24ead4c31a650cfedf3221a5086baf911ea1e544)) + + +### BREAKING CHANGES + +* These new components replace the `Backdrop` and the `ScreenCover`, which had a slightly unclear scope and have now been removed. + +# [5.0.0-alpha.4](https://github.com/dhis2/ui/compare/v5.0.0-alpha.3...v5.0.0-alpha.4) (2020-04-08) + + +### Features + +* add noticebox component ([357ef6d](https://github.com/dhis2/ui/commit/357ef6d45e739d53cf1cef7933ed121259016f54)) + +# [5.0.0-alpha.3](https://github.com/dhis2/ui/compare/v5.0.0-alpha.2...v5.0.0-alpha.3) (2020-04-01) + + +### Code Refactoring + +* use string based selection in multi- and single-select ([e3627a4](https://github.com/dhis2/ui/commit/e3627a479577a7bbd3d78e86f5fbf93e2ca57971)) + + +### BREAKING CHANGES + +* - SingleSelect selection is now a string instead of an object with a value and label property +- MultiSelect selection is now an array of strings instead of an array of objects with a value and label property + +# [5.0.0-alpha.2](https://github.com/dhis2/ui/compare/v5.0.0-alpha.1...v5.0.0-alpha.2) (2020-03-24) + + +### Code Refactoring + +* **core:** add forward refs to base components ([699b194](https://github.com/dhis2/ui/commit/699b1945e83f6551d34137b9a650a580a0918d53)) +* move all core components to widgets ([d6f8a7b](https://github.com/dhis2/ui/commit/d6f8a7b0a379e27a4a0a38968efc2514c3938be5)) + + +### BREAKING CHANGES + +* **core:** base components can hold a ref. +* All @dhis2/ui-core exports have been migrated to +@dhis2/ui-widgets. + +# [5.0.0-alpha.1](https://github.com/dhis2/ui/compare/v4.0.0...v5.0.0-alpha.1) (2020-03-19) + + +### Bug Fixes + +* **root:** update repourl ([7e6eedc](https://github.com/dhis2/ui/commit/7e6eedc9e9048cc98b04a5d25e76b7471249310c)) + + +### Code Refactoring + +* **checkboxfield:** move to ui-widgets ([d979d96](https://github.com/dhis2/ui/commit/d979d96010347d7f22fe40b8af126f9901f5230c)) +* **fileinputfield:** move to ui-widgets ([6059625](https://github.com/dhis2/ui/commit/6059625a46329858e5cc9180ff837d1f3474c796)) +* **fileinputfieldwithlist:** move to ui-widgets ([a512f00](https://github.com/dhis2/ui/commit/a512f007e4716f925502216824ad9a8ac925f2f8)) +* **forms:** add suffix 'Control' ([06896ea](https://github.com/dhis2/ui/commit/06896eadc63dc0aaadfd3d06081e224d4eea5fef)) +* **inputfield:** move to ui-widgets ([50d9009](https://github.com/dhis2/ui/commit/50d9009b5c6fa5effdbcb67fed985f0a87c1cac0)) +* **multiselectfield:** move to ui-widgets ([c3d42ad](https://github.com/dhis2/ui/commit/c3d42ad495eae9858b946dfc8acaa4027febacf7)) +* **singleselectfield:** move to ui-widgets ([e09c70c](https://github.com/dhis2/ui/commit/e09c70cb6a15cd28d28f93a2e11f2c68b7033774)) +* **switchfield:** move to ui-widgets ([2baa52a](https://github.com/dhis2/ui/commit/2baa52a0048818312d0fd3f6b0591341615d604b)) +* **textareafield:** move to ui-widgets ([3ef63da](https://github.com/dhis2/ui/commit/3ef63da69ce5c377a298d9b2af55bac567495e08)) +* **togglegroupfield:** migrate to ui-widgets ([db55448](https://github.com/dhis2/ui/commit/db55448e4aa50d98eafe3d21cefd3b89439b66be)) +* **ui:** list breaking changes ([7ceddf0](https://github.com/dhis2/ui/commit/7ceddf087562e04f70116a9d6ff696ae416cfd59)) + + +### Features + +* **constants:** move and expose the common proptypes ([1bb0f9d](https://github.com/dhis2/ui/commit/1bb0f9d42077bc204923ecd502225c5fa5da3c3a)) +* **forms:** integrate @dhis2/ui-forms ([af49144](https://github.com/dhis2/ui/commit/af49144e20c5b01197e01472c6514129d2abce6f)) +* **ui:** expose @dhis2/ui-forms through metapackage ([88a3782](https://github.com/dhis2/ui/commit/88a3782db4f337e80079824138f76cc5b5c4a6b1)) + + +### BREAKING CHANGES + +* **forms:** Postfix all the @dhis2/ui-forms components with +'Control' to avoid conflicts with the base components in @dhis2/ui-core +and @dhis2/ui-widgets, since all components are now exported in +@dhis2/ui. +* **textareafield:** Import path changes from @dhis2/ui-core to +@dhis2/ui or @dhis2/ui-widgets. +* **switchfield:** Import path for SwichField changes from @dhis2/ui-core +to @dhis2/ui or @dhis2/ui-widgets. +* **singleselectfield:** Import path for SingleSelectField changes from +@dhis2/ui-core to @dhis2/ui or @dhis2/ui-widgets. +* **multiselectfield:** MultiSelectField import path changes from +@dhis2/ui-core to @dhis2/ui-widgets or @dhis2/ui +* **inputfield:** Move InputField from ui-core to ui-widgets, new import +path at @dhis2/ui-widgets or @dhis2/ui. +* **fileinputfieldwithlist:** Move FileInputFieldWithList from ui-core to ui-widgets, +new import from @dhis2/ui-widgets or @dhis2/ui. +* **fileinputfield:** Move FileInputField from ui-core to ui-widgets. New +import path from '@dhis2/ui-widgets' or '@dhis2/ui'. +* **checkboxfield:** CheckboxField has moved from ui-core to ui-widgets. +* **togglegroupfield:** move the ToggleGroupField component from ui-core to +ui-widgets. +* **ui:** Rename the Constrictor component to Box, which is +shorter and thus easier to type. This also expands the capabilities of +Box to make it more Box-like. +* **ui:** Replace SwitchGroupField, RadioGroupField, +CheckboxGroupField with ToggleGroupField. +* **ui:** Replace SwitchGroup, RadioGroup, CheckboxGroup with +ToggleGroup. +* **ui:** The exports: colors, theme, layers, spacers, +spacersNum, and elevations, have been moved from @dhis2/ui-core to +@dhis2/ui-constants for better code reuse. 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..05da098cd1 100644 --- a/README.md +++ b/README.md @@ -1 +1,22 @@ -# ui +# DHIS2 UI + +[![@dhis2/ui on npm](https://img.shields.io/npm/v/@dhis2/ui.svg)](https://www.npmjs.com/package/@dhis2/ui) +[![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) +[![conventional commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) + +--- + +- Docs: [ui.dhis2.nu](https://ui.dhis2.nu) +- Live demo: [ui.dhis2.nu/demo](https://ui.dhis2.nu/demo) +- API reference: [ui.dhis2.nu/api](https://ui.dhis2.nu/api) + +--- + +| Package | Description | Link | +| ------------------- | ----------- | ---------------------------------------------- | +| @dhis2/ui | n/a | [packages/ui](packages/ui) | +| @dhis2/ui-constants | n/a | [packages/ui-constants](packages/ui-constants) | +| @dhis2/ui-core | n/a | [packages/ui-core](packages/ui-core) | +| @dhis2/ui-forms | n/a | [packages/ui-forms](packages/ui-forms) | +| @dhis2/ui-icons | n/a | [packages/ui-icons](packages/ui-icons) | +| @dhis2/ui-widgets | n/a | [packages/ui-widgets](packages/ui-widgets) | 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/Transfer/drag-and-drop-items.feature b/cypress/drafts/Transfer/drag-and-drop-items.feature new file mode 100644 index 0000000000..19a9eb2954 --- /dev/null +++ b/cypress/drafts/Transfer/drag-and-drop-items.feature @@ -0,0 +1,39 @@ +Feature: Drag and drop selected items + + Background: + Given reordering is enabled + + # I have excluded any design information from this scenario: opacity, how the items look when dragging etc. + Scenario: The user clicks, drags and drops a single item + Given the selected list has two or more items + When the user clicks and drags a single item + Then the item should be removed from its original position in the list + And the item should be placed in the list at the cursor position when the click is released + + Scenario: The user clicks, drags and drops multiple items + Given the selected list has three or more items + And the selected list has two or more highlighted items + When the user clicks and drags one of the highlighted items to a new position + Then the items should be removed from their original positions + And the items should be placed in the list at the cursor position when the click is released + And the items should be ordered based on their previous ordering + + Scenario: The user clicks, drags and drops a single item in a list of one + Given the selected list has one item + And the selected list has one highlighted item + When the user clicks, drags and drops the highlighted item + Then the highlighted item should remain in position and shouldn't not move + + + # an attempt to outline how multiple items reorder on drag and drop + Scenario Outline: The user clicks and drags multiple items + Given the selected list has items: + And the items are highlighted + When the user clicks and drags on one of the highlighted items + And releases the click after the position of the list + Then the new order of the items should be + + Examples: + | items | highlight | drop | newOrder | + | 1, 2, 3 , 4, 5 | 2, 4, | fourth | 1, 3, 2, 4 , 5 | + | 1, 2, 3, 4, 5, 6, 7 | 1, 8 | third | 2, 3, 1, 8, 4, 5, 6, 7 | diff --git a/cypress/drafts/Transfer/highlight-keyboard.feature b/cypress/drafts/Transfer/highlight-keyboard.feature new file mode 100644 index 0000000000..ac6f6bf0c5 --- /dev/null +++ b/cypress/drafts/Transfer/highlight-keyboard.feature @@ -0,0 +1,33 @@ +Feature: Highlight options with keyboard controls + + ## + # + # Keyboard interaction + # + # NOTE: For now we won't support keyboard functionality until we have a + # proper keyboard system in place + # + ## + + Scenario Outline: The user presses enter on a focused item that is not already highlighted + Given the list has one or more items + And an item that has keyboard focus + When the user presses the enter or space key + Then the focused item should be highlighted + And any other highlighted items should no longer be highlighted + + Examples: + | type | + | option | + | selected | + + Scenario Outline: The user presses enter on a focused highlighted item + Given the list has one or more items + And an item that has keyboard focus + When the user presses the enter or space key on an item in the list that is highlighted + Then the item is not highlighted + + Examples: + | type | + | option | + | selected | 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/applicationTitle.json b/cypress/fixtures/HeaderBar/applicationTitle.json new file mode 100644 index 0000000000..3fe4e0700a --- /dev/null +++ b/cypress/fixtures/HeaderBar/applicationTitle.json @@ -0,0 +1,3 @@ +{ + "applicationTitle": "Foobar" +} 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..ba16a114f3 --- /dev/null +++ b/cypress/fixtures/HeaderBar/getModules.json @@ -0,0 +1,76 @@ +{ + "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": "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/getModulesWithSpecialChars.json b/cypress/fixtures/HeaderBar/getModulesWithSpecialChars.json new file mode 100644 index 0000000000..8d7f567ae2 --- /dev/null +++ b/cypress/fixtures/HeaderBar/getModulesWithSpecialChars.json @@ -0,0 +1,229 @@ +{ + "modules": [ + { + "name": "/", + "namespace": "//", + "defaultAction": "..//.action", + "displayName": "A / character", + "icon": "../icons/dhis-web-dashboard.png", + "description": "" + }, + { + "name": "-", + "namespace": "/-", + "defaultAction": "../-.action", + "displayName": "A - character", + "icon": "../icons/dhis-web-dashboard.png", + "description": "" + }, + { + "name": "(", + "namespace": "/(", + "defaultAction": "../(.action", + "displayName": "A ( character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": ")", + "namespace": "/)", + "defaultAction": "../).action", + "displayName": "A ) character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "[", + "namespace": "/[", + "defaultAction": "../[.action", + "displayName": "A [ character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "]", + "namespace": "/]", + "defaultAction": "../].action", + "displayName": "A ] character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "{", + "namespace": "/{", + "defaultAction": "../{.action", + "displayName": "A { character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "}", + "namespace": "/}", + "defaultAction": "../}.action", + "displayName": "A } character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "*", + "namespace": "/*", + "defaultAction": "../*.action", + "displayName": "A * character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "+", + "namespace": "/+", + "defaultAction": "../+.action", + "displayName": "A + character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "?", + "namespace": "/?", + "defaultAction": "../?.action", + "displayName": "A ? character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": ".", + "namespace": "/.", + "defaultAction": "../..action", + "displayName": "A . character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": ",", + "namespace": "/,", + "defaultAction": "../,.action", + "displayName": "A , character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "^", + "namespace": "/^", + "defaultAction": "../^.action", + "displayName": "A ^ character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "$", + "namespace": "/$", + "defaultAction": "../$.action", + "displayName": "A $ character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "|", + "namespace": "/|", + "defaultAction": "../|.action", + "displayName": "A | character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "#", + "namespace": "/#", + "defaultAction": "../#.action", + "displayName": "A # character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "\\s", + "namespace": "/\\s", + "defaultAction": "../\\s.action", + "displayName": "A \\s character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "\\", + "namespace": "/\\", + "defaultAction": "../\\.action", + "displayName": "A \\ character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + + { + "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-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-import-export", + "namespace": "/dhis-web-import-export", + "defaultAction": "../dhis-web-import-export/index.action", + "displayName": "Import/Export", + "icon": "../icons/dhis-web-importexport.png", + "description": "" + }, + { + "name": "WHO Metadata browser", + "namespace": "WHO Metadata browser", + "defaultAction": "https://debug.dhis2.org/dev/api/apps/WHO-Metadata-browser/index.html", + "displayName": "", + "icon": "https://debug.dhis2.org/dev/api/apps/WHO-Metadata-browser/icons/medicine-48.png", + "description": "" + }, + { + "name": "Dashboard Classic", + "namespace": "Dashboard Classic", + "defaultAction": "https://debug.dhis2.org/dev/api/apps/Dashboard-Classic/index.html", + "displayName": "", + "icon": "https://debug.dhis2.org/dev/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/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/api.feature b/cypress/integration/AlertBar/api.feature new file mode 100644 index 0000000000..c1b43de19b --- /dev/null +++ b/cypress/integration/AlertBar/api.feature @@ -0,0 +1,28 @@ +Feature: AlertBar API + + 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 + + Scenario: Standard AlertBar with a message + Given an AlertBar with a message is rendered + Then the message will be visible + + 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 + + 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/api/hidden_callback.js b/cypress/integration/AlertBar/api/hidden_callback.js new file mode 100644 index 0000000000..c7a3ae4860 --- /dev/null +++ b/cypress/integration/AlertBar/api/hidden_callback.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().should(win => { + expect(win.onHidden).to.be.calledOnce + expect(win.onHidden).to.be.calledWith({}, null) + }) +}) diff --git a/cypress/integration/AlertBar/api/prop_message.js b/cypress/integration/AlertBar/api/prop_message.js new file mode 100644 index 0000000000..4a89ffcf1b --- /dev/null +++ b/cypress/integration/AlertBar/api/prop_message.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/api/props_message.js b/cypress/integration/AlertBar/api/props_message.js new file mode 100644 index 0000000000..ebdc7418b2 --- /dev/null +++ b/cypress/integration/AlertBar/api/props_message.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/api/props_permanent.js b/cypress/integration/AlertBar/api/props_permanent.js new file mode 100644 index 0000000000..3861384080 --- /dev/null +++ b/cypress/integration/AlertBar/api/props_permanent.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/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/AlertBar/hide.feature b/cypress/integration/AlertBar/hide.feature new file mode 100644 index 0000000000..0dd2269360 --- /dev/null +++ b/cypress/integration/AlertBar/hide.feature @@ -0,0 +1,16 @@ +Feature: Hiding the AlertBar + + 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 + + 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/automatically.js b/cypress/integration/AlertBar/hide/automatically.js new file mode 100644 index 0000000000..cf71d2ee68 --- /dev/null +++ b/cypress/integration/AlertBar/hide/automatically.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.js b/cypress/integration/AlertBar/hide/on_action.js new file mode 100644 index 0000000000..a61048b11e --- /dev/null +++ b/cypress/integration/AlertBar/hide/on_action.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/AlertStack/render_children.feature b/cypress/integration/AlertStack/render_children.feature new file mode 100644 index 0000000000..3da21828b7 --- /dev/null +++ b/cypress/integration/AlertStack/render_children.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/render_children/alertbars.js b/cypress/integration/AlertStack/render_children/alertbars.js new file mode 100644 index 0000000000..c02312f6da --- /dev/null +++ b/cypress/integration/AlertStack/render_children/alertbars.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/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..e6aa9d4131 --- /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().should(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..e751a6815f --- /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().should(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..766a3b359c --- /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().should(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..4af651c18c --- /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().should(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..db75e281b5 --- /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().should(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..d3e8b06f9c --- /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().should(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..ad92abbc3e --- /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().should(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..fc38422594 --- /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').should($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..d08787af53 --- /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-uiwidgets-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..1d47e687c5 --- /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-uiwidgets-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..87ccd55da7 --- /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-uiwidgets-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..62dc6c90b2 --- /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-uiwidgets-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..cc68ca3907 --- /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().should(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..01ec36cd96 --- /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().should(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..64712cb8c5 --- /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/ComponentCover/click_behavior.feature b/cypress/integration/ComponentCover/click_behavior.feature new file mode 100644 index 0000000000..3302d97213 --- /dev/null +++ b/cypress/integration/ComponentCover/click_behavior.feature @@ -0,0 +1,17 @@ +Feature: The ComponentCover has configurable click behaviour + + Scenario: A blocking ComponentCover + Given a ComponentCover with a button below it is rendered + When the user clicks on the button coordinates + Then the onClick handler of the button is not called + + Scenario: A blocking ComponentCover with an onClick handler + Given a ComponentCover with a button in it is rendered + When the user clicks on the ComponentCover + Then the onClick handler of the ComponentCover is called + + Scenario: Clicks orginating from children are ignored + Given a ComponentCover with a button in it is rendered + When the user clicks the button + Then the onClick handler of the button is called + But the onClick handler of the ComponentCover is not called diff --git a/cypress/integration/ComponentCover/click_behavior/index.js b/cypress/integration/ComponentCover/click_behavior/index.js new file mode 100644 index 0000000000..1a5a3f9eb5 --- /dev/null +++ b/cypress/integration/ComponentCover/click_behavior/index.js @@ -0,0 +1,52 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a ComponentCover with a button below it is rendered', () => { + cy.visitStory('ComponentCover', 'Blocking') +}) + +Given('a ComponentCover with a button in it is rendered', () => { + cy.visitStory('ComponentCover', 'With Click Handler') +}) + +When('the user clicks the button', () => { + cy.get('button').click() +}) + +When('the user clicks on the ComponentCover', () => { + cy.get('[data-test="dhis2-uicore-componentcover"]').click() +}) + +When('the user clicks on the button coordinates', () => { + cy.getPositionsBySelectors('button').then(([rect]) => { + // Get button center coordinates + const buttonCenterX = rect.left + rect.width / 2 + const buttonCenterY = rect.top + rect.height / 2 + + // click body on the button center + cy.get('body').click(buttonCenterX, buttonCenterY) + }) +}) + +Then('the onClick handler of the button is called', () => { + cy.window().should(win => { + expect(win.onButtonClick).to.be.calledOnce + }) +}) + +Then('the onClick handler of the ComponentCover is called', () => { + cy.window().should(win => { + expect(win.onComponentCoverClick).to.be.calledOnce + }) +}) + +Then('the onClick handler of the button is not called', () => { + cy.window().should(win => { + expect(win.onButtonClick).to.have.callCount(0) + }) +}) + +Then('the onClick handler of the ComponentCover is not called', () => { + cy.window().should(win => { + expect(win.onComponentCoverClick).to.have.callCount(0) + }) +}) 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..f56d4c0437 --- /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().should(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..cdef418841 --- /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().should(win => { + expect(win.onClick).not.to.be.called + }) +}) 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/DropdownButton/opens_a_dropdown.feature b/cypress/integration/DropdownButton/opens_a_dropdown.feature new file mode 100644 index 0000000000..017874532e --- /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 user clicks the backdrop Layer + 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..39a2c47253 --- /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 user clicks the backdrop Layer', () => { + cy.get('[data-test="dhis2-uicore-layer"]').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/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/FieldGroup/can_be_required.feature b/cypress/integration/FieldGroup/can_be_required.feature new file mode 100644 index 0000000000..3e99c5d0b4 --- /dev/null +++ b/cypress/integration/FieldGroup/can_be_required.feature @@ -0,0 +1,5 @@ +Feature: Required status for the FieldGroup + + Scenario: Rendering a FieldGroup that is required + Given a FieldGroup with label and a required flag is rendered + Then the required indicator is visible diff --git a/cypress/integration/FieldGroup/can_be_required/index.js b/cypress/integration/FieldGroup/can_be_required/index.js new file mode 100644 index 0000000000..50e6128e00 --- /dev/null +++ b/cypress/integration/FieldGroup/can_be_required/index.js @@ -0,0 +1,11 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FieldGroup with label and a required flag is rendered', () => { + cy.visitStory('FieldGroup', 'With label and required') +}) + +Then('the required indicator is visible', () => { + cy.get('[data-test="dhis2-uicore-field-label-required"]').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..0a54e3ee46 --- /dev/null +++ b/cypress/integration/FileInput/accepts_multiple_files/index.js @@ -0,0 +1,33 @@ +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.window().should(win => { + const calls = win.onChange.getCalls() + const callArgs = calls[0].args + const payload = callArgs[0] + 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..4727834a79 --- /dev/null +++ b/cypress/integration/FileInput/can_be_blurred/index.js @@ -0,0 +1,22 @@ +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').should( + 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..c64eada610 --- /dev/null +++ b/cypress/integration/FileInput/can_be_changed/index.js @@ -0,0 +1,27 @@ +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.window().should(win => { + const calls = win.onChange.getCalls() + const callArgs = calls[0].args + const payload = callArgs[0] + 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..000c3d39de --- /dev/null +++ b/cypress/integration/FileInput/can_be_focused/index.js @@ -0,0 +1,22 @@ +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').should( + 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..1f418347e6 --- /dev/null +++ b/cypress/integration/FileInput/common/index.js @@ -0,0 +1,21 @@ +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().should(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']) + }) +}) 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/FileInputField/has_default_button_label.feature b/cypress/integration/FileInputField/has_default_button_label.feature new file mode 100644 index 0000000000..ffcd460936 --- /dev/null +++ b/cypress/integration/FileInputField/has_default_button_label.feature @@ -0,0 +1,5 @@ +Feature: Button label for the FileInputField + + Scenario: Rendering a FileInputField + Given a default FileInputField is rendered + Then the default button label is visible diff --git a/cypress/integration/FileInputField/has_default_button_label/index.js b/cypress/integration/FileInputField/has_default_button_label/index.js new file mode 100644 index 0000000000..74f089078c --- /dev/null +++ b/cypress/integration/FileInputField/has_default_button_label/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default FileInputField is rendered', () => { + cy.visitStory('FileInputField', 'Default') +}) + +Then('the default button label is visible', () => { + cy.contains('Upload a file').should('be.visible') +}) diff --git a/cypress/integration/FileInputField/has_default_placeholder.feature b/cypress/integration/FileInputField/has_default_placeholder.feature new file mode 100644 index 0000000000..196dda7627 --- /dev/null +++ b/cypress/integration/FileInputField/has_default_placeholder.feature @@ -0,0 +1,5 @@ +Feature: Placeholder for the FileInputField + + Scenario: Rendering a FileInputField + Given a default FileInputField is rendered + Then the default placeholder is visible diff --git a/cypress/integration/FileInputField/has_default_placeholder/index.js b/cypress/integration/FileInputField/has_default_placeholder/index.js new file mode 100644 index 0000000000..479933862c --- /dev/null +++ b/cypress/integration/FileInputField/has_default_placeholder/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default FileInputField is rendered', () => { + cy.visitStory('FileInputField', 'Default') +}) + +Then('the default placeholder is visible', () => { + cy.contains('No file uploaded yet').should('be.visible') +}) diff --git a/cypress/integration/FileInputFieldWithList/common/index.js b/cypress/integration/FileInputFieldWithList/common/index.js new file mode 100644 index 0000000000..b2c8089f8d --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/common/index.js @@ -0,0 +1,18 @@ +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().should(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']) + }) +}) 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..21b9362309 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/deduplicates_the_file_list/index.js @@ -0,0 +1,44 @@ +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 + }) + .trigger('change', { force: true }) + }) +}) + +Then( + "the onChange handler's payload contains a single entry for file.md", + () => { + cy.window().should(win => { + const calls = win.onChange.getCalls() + const callArgs = calls[0].args + const payload = callArgs[0] + 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..e85aaee451 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/files_can_be_removed/index.js @@ -0,0 +1,25 @@ +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.window().should(win => { + const calls = win.onChange.getCalls() + const callArgs = calls[0].args + + const payload = callArgs[0] + const files = payload.files + expect(files).to.have.lengthOf(2) + + const filtered = files.filter(file => file.name === 'test1.md') + expect(filtered).to.have.lengthOf(0) + }) + } +) diff --git a/cypress/integration/FileInputFieldWithList/has_default_button_label.feature b/cypress/integration/FileInputFieldWithList/has_default_button_label.feature new file mode 100644 index 0000000000..30be53a624 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/has_default_button_label.feature @@ -0,0 +1,5 @@ +Feature: Button label for the FileInputFieldWithList + + Scenario: Rendering a FileInputFieldWithList + Given a default FileInputFieldWithList is rendered + Then the default button label is visible diff --git a/cypress/integration/FileInputFieldWithList/has_default_button_label/index.js b/cypress/integration/FileInputFieldWithList/has_default_button_label/index.js new file mode 100644 index 0000000000..43fbbcaaa7 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/has_default_button_label/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default FileInputFieldWithList is rendered', () => { + cy.visitStory('FileInputFieldWithList', 'With default texts') +}) + +Then('the default button label is visible', () => { + cy.contains('Upload a file').should('be.visible') +}) diff --git a/cypress/integration/FileInputFieldWithList/has_default_placeholder.feature b/cypress/integration/FileInputFieldWithList/has_default_placeholder.feature new file mode 100644 index 0000000000..3cb31230f2 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/has_default_placeholder.feature @@ -0,0 +1,5 @@ +Feature: Placeholder for the FileInputFieldWithList + + Scenario: Rendering a FileInputFieldWithList + Given a default FileInputFieldWithList is rendered + Then the default placeholder is visible diff --git a/cypress/integration/FileInputFieldWithList/has_default_placeholder/index.js b/cypress/integration/FileInputFieldWithList/has_default_placeholder/index.js new file mode 100644 index 0000000000..d66afddd94 --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/has_default_placeholder/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default FileInputFieldWithList is rendered', () => { + cy.visitStory('FileInputFieldWithList', 'With default texts') +}) + +Then('the default placeholder is visible', () => { + cy.contains('No file uploaded yet').should('be.visible') +}) diff --git a/cypress/integration/FileInputFieldWithList/has_default_remove_text.feature b/cypress/integration/FileInputFieldWithList/has_default_remove_text.feature new file mode 100644 index 0000000000..231627c5ff --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/has_default_remove_text.feature @@ -0,0 +1,5 @@ +Feature: Placeholder for the FileInputFieldWithList + + Scenario: Rendering a FileInputFieldWithList + Given a default FileInputFieldWithList is rendered + Then the default remove text is visible diff --git a/cypress/integration/FileInputFieldWithList/has_default_remove_text/index.js b/cypress/integration/FileInputFieldWithList/has_default_remove_text/index.js new file mode 100644 index 0000000000..d4ec0da90e --- /dev/null +++ b/cypress/integration/FileInputFieldWithList/has_default_remove_text/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default FileInputFieldWithList is rendered', () => { + cy.visitStory('FileInputFieldWithList', 'With file and default texts') +}) + +Then('the default remove text is visible', () => { + cy.contains('Remove').should('be.visible') +}) 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..e5bdb4242a --- /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().should(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..ed2d11235a --- /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().should(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/FlyoutMenu/accepts_children.feature b/cypress/integration/FlyoutMenu/accepts_children.feature new file mode 100644 index 0000000000..7785383023 --- /dev/null +++ b/cypress/integration/FlyoutMenu/accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The FlyoutMenu renders children + + Scenario: A FlyoutMenu with children + Given a FlyoutMenu with children is rendered + Then the children are visible diff --git a/cypress/integration/FlyoutMenu/accepts_children/index.js b/cypress/integration/FlyoutMenu/accepts_children/index.js new file mode 100644 index 0000000000..b3cceced65 --- /dev/null +++ b/cypress/integration/FlyoutMenu/accepts_children/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FlyoutMenu with children is rendered', () => { + cy.visitStory('FlyoutMenu', '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/FlyoutMenu/position.feature b/cypress/integration/FlyoutMenu/position.feature new file mode 100644 index 0000000000..afa14e33c4 --- /dev/null +++ b/cypress/integration/FlyoutMenu/position.feature @@ -0,0 +1,18 @@ +Feature: Position of a SubMenu component + Scenario: Default rendering + Given there is enough space to the right of the MenuItem to fit the SubMenu + When the user clicks on the MenuItem + Then the right of the MenuItem is aligned with the left of the SubMenu + And the top of the MenuItem is aligned with the top of the SubMenu wrapper + + Scenario: Flipped rendering when insufficient space to the right of + Given there is not enough space to the right of the MenuItem to fit the SubMenu + When the user clicks on the MenuItem + Then the left of the MenuItem is aligned with the right of the SubMenu + And the top of the MenuItem is aligned with the top of the SubMenu wrapper + + Scenario: Shifting into view when insufficient space to the right of and above + Given there is not enough space to the right or the left of the MenuItem to fit the SubMenu + When the user clicks on the MenuItem + Then the SubMenu is rendered on top of the MenuItem + And the top of the MenuItem is aligned with the top of the SubMenu wrapper diff --git a/cypress/integration/FlyoutMenu/position/index.js b/cypress/integration/FlyoutMenu/position/index.js new file mode 100644 index 0000000000..cca8672989 --- /dev/null +++ b/cypress/integration/FlyoutMenu/position/index.js @@ -0,0 +1,70 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'there is enough space to the right of the MenuItem to fit the SubMenu', + () => { + cy.visitStory('FlyoutMenu', 'Default Position') + } +) + +Given( + 'there is not enough space to the right of the MenuItem to fit the SubMenu', + () => { + cy.visitStory('FlyoutMenu', 'Flipped Position') + } +) + +Given( + 'there is not enough space to the right or the left of the MenuItem to fit the SubMenu', + () => { + cy.visitStory('FlyoutMenu', 'Shift Into View') + } +) + +When('the user clicks on the MenuItem', () => { + cy.get('[data-test="dhis2-uicore-menuitem"]').click() +}) + +Then( + 'the right of the MenuItem is aligned with the left of the SubMenu', + () => { + getMenuItemAndSubMenuRects().should(([menuItemRect, subMenuRect]) => { + expect(menuItemRect.right).to.equal(subMenuRect.left) + }) + } +) + +Then( + 'the left of the MenuItem is aligned with the right of the SubMenu', + () => { + getMenuItemAndSubMenuRects().should(([menuItemRect, subMenuRect]) => { + expect(menuItemRect.left).to.equal(subMenuRect.right) + }) + } +) + +Then('the SubMenu is rendered on top of the MenuItem', () => { + getMenuItemAndSubMenuRects().should(([menuItemRect, subMenuRect]) => { + expect(menuItemRect.right).to.be.greaterThan(subMenuRect.left) + expect(subMenuRect.right).to.be.greaterThan(menuItemRect.right) + }) +}) + +Then( + 'the top of the MenuItem is aligned with the top of the SubMenu wrapper', + () => { + cy.getPositionsBySelectors( + '[data-test="dhis2-uicore-menuitem"]', + '[data-test="dhis2-uicore-popper"]' + ).should(([menuItemRect, popperRect]) => { + expect(menuItemRect.top).to.equal(popperRect.top) + }) + } +) + +function getMenuItemAndSubMenuRects() { + return cy.getPositionsBySelectors( + '[data-test="dhis2-uicore-menuitem"]', + '[data-test="dhis2-uicore-popper"]' + ) +} diff --git a/cypress/integration/FlyoutMenu/toggles_submenus.feature b/cypress/integration/FlyoutMenu/toggles_submenus.feature new file mode 100644 index 0000000000..e176d1387f --- /dev/null +++ b/cypress/integration/FlyoutMenu/toggles_submenus.feature @@ -0,0 +1,9 @@ +Feature: The FlyoutMenu toggles SubMenus + + Scenario: a FlyoutMenu with two SubMenus + Given a FlyoutMenu with two SubMenus is rendered + When the user clicks the first SubMenu anchor + Then first SubMenu is visible + When the user clicks the second SubMenu anchor + Then second SubMenu is visible + And the first SubMenu is not visible diff --git a/cypress/integration/FlyoutMenu/toggles_submenus/index.js b/cypress/integration/FlyoutMenu/toggles_submenus/index.js new file mode 100644 index 0000000000..93e6dea417 --- /dev/null +++ b/cypress/integration/FlyoutMenu/toggles_submenus/index.js @@ -0,0 +1,26 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a FlyoutMenu with two SubMenus is rendered', () => { + cy.visitStory('FlyoutMenu', 'Toggles Sub Menus') + cy.get('[data-test="dhis2-uicore-menu"]').should('be.visible') +}) + +When('the user clicks the first SubMenu anchor', () => { + cy.contains('Item 1').click() +}) + +Then('first SubMenu is visible', () => { + cy.contains('SubMenu 1').should('be.visible') +}) + +When('the user clicks the second SubMenu anchor', () => { + cy.contains('Item 2').click() +}) + +Then('second SubMenu is visible', () => { + cy.contains('SubMenu 2').should('be.visible') +}) + +Then('the first SubMenu is not visible', () => { + cy.contains('SubMenu 1').should('not.be.visible') +}) diff --git a/cypress/integration/HeaderBar/common/index.js b/cypress/integration/HeaderBar/common/index.js new file mode 100644 index 0000000000..071216e2e1 --- /dev/null +++ b/cypress/integration/HeaderBar/common/index.js @@ -0,0 +1,52 @@ +import { Before, Given } from 'cypress-cucumber-preprocessor/steps' + +export const baseUrl = 'https://domain.tld/' +export const webCommons = 'https://domain.tld/dhis-web-commons/' + +Before(() => { + cy.fixture('HeaderBar/applicationTitle').as('applicationTitleFixture') + 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') + cy.server() + + cy.get('@applicationTitleFixture').then(fx => { + cy.route({ + url: `${baseUrl}/api/systemSettings/applicationTitle`, + response: fx, + }).as('applicationTitle') + }) + + cy.get('@meFixture').then(fx => { + cy.route({ + url: `${baseUrl}/api/me`, + response: fx, + }).as('me') + }) + + cy.get('@modulesFixture').then(fx => { + cy.route({ + url: `${baseUrl}/dhis-web-commons/menu/getModules.action`, + response: fx, + }).as('modules') + }) + + cy.get('@dashboardFixture').then(fx => { + cy.route({ + url: `${baseUrl}/api/me/dashboard`, + response: fx, + }).as('dashboard') + }) + + cy.get('@logoFixture').then(fx => { + cy.route({ + url: `${baseUrl}/api/staticContent/logo_banner`, + response: fx, + }).as('logo_banner') + }) +}) + +Given('the HeaderBar loads without an error', () => { + cy.visitStory('HeaderBarTesting', 'Default') +}) 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/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_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_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_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..7b1de7a600 --- /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,28 @@ +import { webCommons } from '../common/index.js' +import { When, Then, Given } from 'cypress-cucumber-preprocessor/steps' + +Given('there are 5 apps available to the user', () => { + cy.get('@modulesFixture').then(fx => { + cy.route({ + url: `${webCommons}menu/getModules.action`, + response: { + ...fx, + modules: fx.modules.slice(0, 5), + }, + }).as('modules') + }) +}) + +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_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/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_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..349117986a --- /dev/null +++ b/cypress/integration/HeaderBar/the_headerbar_contains_a_profile_menu/the_user_name_and_email_are_displayed.js @@ -0,0 +1,19 @@ +import '../common/index' +import { Then } from 'cypress-cucumber-preprocessor/steps' + +Then('contains the user name', () => { + cy.getAll('@meFixture', '[data-test="headerbar-profile-username"]').should( + ([{ name }, $name]) => { + expect($name.text()).to.equal(name) + } + ) +}) + +Then('contains the user email', () => { + cy.getAll( + '@meFixture', + '[data-test="headerbar-profile-user-email"]' + ).should(([{ email }, $email]) => { + expect($email.text()).to.equal(email) + }) +}) 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..7b2eb1ac33 --- /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"]').should($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..3581dadf73 --- /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"]').should($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..93fb3e68b5 --- /dev/null +++ b/cypress/integration/HeaderBar/the_headerbar_should_contain_a_logo_that_links_to_the_homepage/headerbar_contains_logo.js @@ -0,0 +1,12 @@ +import { Then } from 'cypress-cucumber-preprocessor/steps' +import { baseUrl } from '../common/index.js' + +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('[data-test="headerbar-logo"] a').should($a => { + expect($a.attr('href')).to.equal(baseUrl) + }) +}) 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..1a26ae55e1 --- /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 { baseUrl } from '../common/index' +import { Then, Given } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'the custom title is {string} and the app title is "Example!"', + applicationTitle => { + cy.get('@applicationTitleFixture').then(fx => { + cy.route({ + url: `${baseUrl}api/systemSettings/applicationTitle`, + response: { ...fx, applicationTitle }, + }).as('applicationTitle') + }) + } +) + +Then('the displayed title should be "Barbaz - Example!"', () => { + cy.get('[data-test="headerbar-title"]').should($title => { + expect($title.text()).to.equal('Barbaz - Example!') + }) +}) diff --git a/cypress/integration/HeaderBar/the_search_should_escape_regexp_character.feature b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character.feature new file mode 100644 index 0000000000..29a4621c77 --- /dev/null +++ b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character.feature @@ -0,0 +1,49 @@ +Feature: The search should escape regexp characters + + Scenario Outline: The user searches for an app with a regex character + Given some app names contain a + And the HeaderBar loads without an error + And the search contains a + Then only apps with in their name should be shown + + Examples: + | char | + | / | + | ( | + | ) | + | [ | + | ] | + | { | + | } | + | * | + | + | + | ? | + | . | + | ^ | + | $ | + | \| | + | \\ | + + Scenario Outline: The modules do not contain items with special chars + Given the HeaderBar loads without an error + And the search contains a + And no app name contains a + Then no results should be shown + + Examples: + | char | + | / | + | ( | + | ) | + | [ | + | ] | + | { | + | } | + | * | + | + | + | ? | + | . | + | ^ | + | $ | + | \| | + | \\ | diff --git a/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/common.js b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/common.js new file mode 100644 index 0000000000..896e2b491c --- /dev/null +++ b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/common.js @@ -0,0 +1,6 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given(/the search contains a (.*)/, character => { + cy.get('[data-test="headerbar-apps-icon"]').click() + cy.get('#filter').type(character) +}) diff --git a/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js new file mode 100644 index 0000000000..6f22df3a1c --- /dev/null +++ b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js @@ -0,0 +1,20 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given(/no app name contains a (.*)/, character => { + cy.wrap(character).then(char => { + cy.get('@modulesFixture').then(fx => { + const modulesWithSpecialChar = fx.modules.filter(module => { + return module.displayName.indexOf(char) !== -1 + }) + + expect(modulesWithSpecialChar).to.have.length(0) + }) + }) +}) + +Then('no results should be shown', () => { + cy.get('[data-test="headerbar-apps-menu-list"] > a > div').should( + 'not.exist' + ) +}) diff --git a/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js new file mode 100644 index 0000000000..74e18e5665 --- /dev/null +++ b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js @@ -0,0 +1,37 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given(/some app names contain a (.*)/, character => { + // use fixture with special chars + cy.fixture('HeaderBar/getModulesWithSpecialChars').as('modulesFixture') + + // set fixture as response of the modules action endpoint + cy.get('@modulesFixture').then(fx => { + cy.route({ + url: 'https://domain.tld/dhis-web-commons/menu/getModules.action', + response: fx, + }).as('modules') + }) + + // verify that there's a module with the special char in its name + cy.wrap(character).then(char => { + cy.get('@modulesFixture').then(fx => { + const modulesWithSpecialChar = fx.modules.filter(module => { + return module.displayName.indexOf(char) !== -1 + }) + + expect(modulesWithSpecialChar).to.have.length.of.at.least(1) + }) + }) +}) + +Then(/only apps with (.*) in their name should be shown/, character => { + cy.get('[data-test="headerbar-apps-menu-list"] > a > div').should( + $modules => { + $modules.each((index, module) => { + const displayName = Cypress.$(module).text() + expect(displayName.indexOf(character)).to.not.eql(-1) + }) + } + ) +}) 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..91f79acbe8 --- /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().should(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..933eb6747f --- /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().should(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..964a7505c3 --- /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().should(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..4b421fd551 --- /dev/null +++ b/cypress/integration/InputField/can_be_required/index.js @@ -0,0 +1,11 @@ +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-uiwidgets-inputfield-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/Layer/accepts_children.feature b/cypress/integration/Layer/accepts_children.feature new file mode 100644 index 0000000000..5ffe48e8a3 --- /dev/null +++ b/cypress/integration/Layer/accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The Layer renders children + + Scenario: A Layer with children + Given a Layer with children is rendered + Then the children are visible diff --git a/cypress/integration/Layer/accepts_children/index.js b/cypress/integration/Layer/accepts_children/index.js new file mode 100644 index 0000000000..b687dc83d8 --- /dev/null +++ b/cypress/integration/Layer/accepts_children/index.js @@ -0,0 +1,8 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Layer with children is rendered', () => { + cy.visitStory('Layer', 'Default') +}) +Then('the children are visible', () => { + cy.contains('I am a child').should('be.visible') +}) diff --git a/cypress/integration/Layer/click_behavior.feature b/cypress/integration/Layer/click_behavior.feature new file mode 100644 index 0000000000..8bbbec12ac --- /dev/null +++ b/cypress/integration/Layer/click_behavior.feature @@ -0,0 +1,17 @@ +Feature: The Layer has configurable click behaviour + + Scenario: A blocking layer + Given a Layer with a button below it is rendered + When the user clicks on the button coordinates + Then the onClick handler of the button is not called + + Scenario: A blocking layer with an onClick handler + Given a Layer with a button in it is rendered + When the user clicks on the layer + Then the onClick handler of the layer is called + + Scenario: Clicks orginating from children are ignored + Given a Layer with a button in it is rendered + When the user clicks the button + Then the onClick handler of the button is called + But the onClick handler of the layer is not called diff --git a/cypress/integration/Layer/click_behavior/index.js b/cypress/integration/Layer/click_behavior/index.js new file mode 100644 index 0000000000..8e7f92afdb --- /dev/null +++ b/cypress/integration/Layer/click_behavior/index.js @@ -0,0 +1,52 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a Layer with a button below it is rendered', () => { + cy.visitStory('Layer', 'Blocking') +}) + +Given('a Layer with a button in it is rendered', () => { + cy.visitStory('Layer', 'With Click Handler') +}) + +When('the user clicks the button', () => { + cy.get('button').click() +}) + +When('the user clicks on the layer', () => { + cy.get('[data-test="dhis2-uicore-layer"]').click() +}) + +When('the user clicks on the button coordinates', () => { + cy.getPositionsBySelectors('button').then(([rect]) => { + // Get button center coordinates + const buttonCenterX = rect.left + rect.width / 2 + const buttonCenterY = rect.top + rect.height / 2 + + // click body on the button center + cy.get('body').click(buttonCenterX, buttonCenterY) + }) +}) + +Then('the onClick handler of the button is called', () => { + cy.window().should(win => { + expect(win.onButtonClick).to.be.calledOnce + }) +}) + +Then('the onClick handler of the layer is called', () => { + cy.window().should(win => { + expect(win.onLayerClick).to.be.calledOnce + }) +}) + +Then('the onClick handler of the button is not called', () => { + cy.window().should(win => { + expect(win.onButtonClick).to.have.callCount(0) + }) +}) + +Then('the onClick handler of the layer is not called', () => { + cy.window().should(win => { + expect(win.onLayerClick).to.have.callCount(0) + }) +}) diff --git a/cypress/integration/Layer/stacking.feature b/cypress/integration/Layer/stacking.feature new file mode 100644 index 0000000000..64a25d01c1 --- /dev/null +++ b/cypress/integration/Layer/stacking.feature @@ -0,0 +1,34 @@ +Feature: Layers are stacked on top of each other + + Scenario: Equal sibling layers + Given two equal sibling Layers are rendered + Then the second layer is on top of the first layer + + Scenario: Inequal sibling layers + Given an alert, blocking, and applicatioTop Layer are rendered as siblings + Then the alert layer is on top + + # use zIndex stacking context + Scenario: Nesting Layer elements with lower levels + Given a blocking layer is rendered as the child of an alert layer + Then the blocking layer is on top + And the blocking layer is a child of the alert layer + + # avoid stacking context upper bound issue + Scenario: Appending nested Layers with higher levels to the body + Given an alert layer is rendered as the child of a blocking layer + Then the alert layer is on top + And the alert layer is a sibling of the blocking layer + + # verify that bug from previous implementation is not there + # that bug was as follows: + # nested layers top element zIndex = 1000 + 1 + 1 = 1002 + # sibling layer element zIndex = 1001 + # so layer level 1001 would be below the nested layer with level 1000 + Scenario: Levels are respected when nesting layers + Given a layer with level 1001 is a sibling of 3 nested layers with level 1000 + Then the layer with level 1001 is on top + + Scenario: Nested higher levels still end up on top + Given an applicatioTop layer with a nested alert layer with a blocking sibling + Then the alert layer is on top diff --git a/cypress/integration/Layer/stacking/index.js b/cypress/integration/Layer/stacking/index.js new file mode 100644 index 0000000000..6c73624c9b --- /dev/null +++ b/cypress/integration/Layer/stacking/index.js @@ -0,0 +1,78 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('two equal sibling Layers are rendered', () => { + cy.visitStory('Layer', 'Equal Siblings') +}) + +Given( + 'an alert, blocking, and applicatioTop Layer are rendered as siblings', + () => { + cy.visitStory('Layer', 'Inequal Siblings') + } +) + +Given('a blocking layer is rendered as the child of an alert layer', () => { + cy.visitStory('Layer', 'Nested Lower Levels') +}) + +Given('an alert layer is rendered as the child of a blocking layer', () => { + cy.visitStory('Layer', 'Nested Higher Levels') +}) + +Given( + 'a layer with level 1001 is a sibling of 3 nested layers with level 1000', + () => { + cy.visitStory('Layer', 'Levels Are Respected When Nesting') + } +) + +Given( + 'an applicatioTop layer with a nested alert layer with a blocking sibling', + () => { + cy.visitStory('Layer', 'Nested Higher Level Ends On Top') + } +) + +Then('the second layer is on top of the first layer', () => { + cy.get('body').click() + cy.window().should(win => { + expect(win.onLayerClick).to.be.calledOnce + expect(win.onLayerClick).to.be.calledWith('second') + }) +}) + +Then('the alert layer is on top', () => { + cy.get('body').click() + cy.window().should(win => { + expect(win.onLayerClick).to.be.calledOnce + expect(win.onLayerClick).to.be.calledWith('alert') + }) +}) + +Then('the layer with level 1001 is on top', () => { + cy.get('body').click() + cy.window().should(win => { + expect(win.onLayerClick).to.be.calledOnce + expect(win.onLayerClick).to.be.calledWith('1001') + }) +}) + +Then('the blocking layer is on top', () => { + cy.get('body').click() + cy.window().should(win => { + expect(win.onLayerClick).to.be.calledOnce + expect(win.onLayerClick).to.be.calledWith('blocking') + }) +}) + +Then('the blocking layer is a child of the alert layer', () => { + cy.get('[data-test="blocking"]') + .parent() + .should('have.data', 'test', 'alert') +}) + +Then('the alert layer is a sibling of the blocking layer', () => { + cy.get('[data-test="blocking"]') + .next() + .should('have.data', 'test', 'alert') +}) 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..6b7f951b7a --- /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-menulist"]').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..f655b8d3ab --- /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..24221621f6 --- /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..2dd271d7ac --- /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 is visible \ No newline at end of file diff --git a/cypress/integration/MenuItem/accepts_label/index.js b/cypress/integration/MenuItem/accepts_label/index.js new file mode 100644 index 0000000000..ec48d8d87f --- /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 is 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..841abfceca --- /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..606bb415fd --- /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 On Click 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().should(win => { + expect(win.onClick).to.be.calledWith({ + value: 'Value', + }) + }) +}) diff --git a/cypress/integration/MenuSectionHeader/accepts_label.feature b/cypress/integration/MenuSectionHeader/accepts_label.feature new file mode 100644 index 0000000000..8bf9674564 --- /dev/null +++ b/cypress/integration/MenuSectionHeader/accepts_label.feature @@ -0,0 +1,5 @@ +Feature: The MenuSectionHeader accepts a label prop + + Scenario: MenuSectionHeader renders supplied label + Given a MenuSectionHeader supplied with a label is rendered + Then the label is visible \ No newline at end of file diff --git a/cypress/integration/MenuSectionHeader/accepts_label/index.js b/cypress/integration/MenuSectionHeader/accepts_label/index.js new file mode 100644 index 0000000000..70bb3d779a --- /dev/null +++ b/cypress/integration/MenuSectionHeader/accepts_label/index.js @@ -0,0 +1,10 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a MenuSectionHeader supplied with a label is rendered', () => { + cy.visitStory('MenuSectionHeader', 'With Label') + cy.get('[data-test="dhis2-uicore-menusectionheader"]').should('be.visible') +}) + +Then('the label is visible', () => { + cy.contains('label').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..9a485b5495 --- /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-layer"]').click('topLeft') +}) + +Then('the onClose handler is called', () => { + cy.window().should(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..3dba87e52e --- /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 layer + 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..904fed4a05 --- /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().should(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..68210a3608 --- /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().should(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..3d7dec5ad0 --- /dev/null +++ b/cypress/integration/MultiSelect/allows_invalid_options/index.js @@ -0,0 +1,28 @@ +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"] input').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..b1ae04a133 --- /dev/null +++ b/cypress/integration/MultiSelect/allows_selecting.feature @@ -0,0 +1,44 @@ +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 + + Scenario: The user clicks an option twice to select and deselect it + Given a MultiSelect is rendered to which options can be added + And the MultiSelect is open + When an option is clicked + Then the clicked option is selected + When the selected option is clicked again + Then the previously selected option is deselected diff --git a/cypress/integration/MultiSelect/allows_selecting/index.js b/cypress/integration/MultiSelect/allows_selecting/index.js new file mode 100644 index 0000000000..58f1ad274c --- /dev/null +++ b/cypress/integration/MultiSelect/allows_selecting/index.js @@ -0,0 +1,82 @@ +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-layer"]') + .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() +}) + +When('the selected option is clicked again', () => { + cy.get('[data-test="dhis2-uicore-multiselectoption"] label') + .contains('option one') + .click() +}) + +Then('the clicked option is selected', () => { + cy.window().should(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ + selected: ['1'], + }) + }) +}) + +Then('the clicked option is selected as well', () => { + cy.window().should(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ + selected: ['1', '2'], + }) + }) +}) + +Then('the selected option is deselected', () => { + cy.window().should(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ selected: [] }) + }) +}) + +Then('the onchange handler is not called', () => { + cy.window().should(win => { + expect(win.onChange).to.not.be.called + }) +}) + +Then('the previously selected option is deselected', () => { + cy.window().should(win => { + expect(win.onChange).to.be.calledTwice + expect(win.onChange).to.be.calledWith({ selected: [] }) + }) +}) 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..004a253878 --- /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().should(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..28b8bbc310 --- /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-layer"]').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..95b6b78a64 --- /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 layer to hide the options + Given a MultiSelect with options is rendered + And the MultiSelect is open + When the user clicks the backdrop layer + 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/common/index.js b/cypress/integration/MultiSelect/common/index.js new file mode 100644 index 0000000000..c1a561d82e --- /dev/null +++ b/cypress/integration/MultiSelect/common/index.js @@ -0,0 +1,49 @@ +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('a MultiSelect is rendered to which options can be added', () => { + cy.visitStory('MultiSelect', 'With options that can be added to the input') + cy.get('[data-test="dhis2-uicore-multiselect"]').should('exist') +}) + +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 layer', () => { + cy.get('[data-test="dhis2-uicore-layer"]').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/MultiSelect/duplicate_option_values.feature b/cypress/integration/MultiSelect/duplicate_option_values.feature new file mode 100644 index 0000000000..932dd17aef --- /dev/null +++ b/cypress/integration/MultiSelect/duplicate_option_values.feature @@ -0,0 +1,7 @@ +Feature: Duplicate option values result in a conflict between the input and dropdown UI + + Scenario: The MultiSelect has options with a duplicate value and this value is selected + Given a MultiSelect with options with a duplicate value and this value is selected + And the MultiSelect is open + Then the first option with the selected value is displayed in the input + But both options are highlighted in the dropdown diff --git a/cypress/integration/MultiSelect/duplicate_option_values/index.js b/cypress/integration/MultiSelect/duplicate_option_values/index.js new file mode 100644 index 0000000000..5876097c55 --- /dev/null +++ b/cypress/integration/MultiSelect/duplicate_option_values/index.js @@ -0,0 +1,22 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a MultiSelect with options with a duplicate value and this value is selected', + () => { + cy.visitStory('MultiSelect', 'With duplicate selected option values') + } +) +Then( + 'the first option with the selected value is displayed in the input', + () => { + cy.get('[data-test="dhis2-uicore-select-input"]').should( + 'contain', + 'option one' + ) + } +) +Then('both options are highlighted in the dropdown', () => { + cy.get('input[name="option one"]').should('have.attr', 'checked') + cy.get('input[name="option one a"]').should('have.attr', 'checked') +}) diff --git a/cypress/integration/MultiSelect/position.feature b/cypress/integration/MultiSelect/position.feature new file mode 100644 index 0000000000..7ecce17bd6 --- /dev/null +++ b/cypress/integration/MultiSelect/position.feature @@ -0,0 +1,47 @@ +Feature: Position of MultiSelect menu dropdown + + Scenario: Default rendering + Given there is enough space below the anchor to fit the MultiSelect menu + When the MultiSelect is clicked + Then the top of the menu is aligned with the bottom of the input + And the left of the Menu is aligned with the left of the Input + + Scenario: Flipped rendering when insufficient space below + Given there is not enough space below the anchor to fit the MultiSelect menu + When the MultiSelect is clicked + Then the bottom of the menu is aligned with the top of the input + And the left of the Menu is aligned with the left of the Input + + Scenario: Shifting into view when insufficient space below and above + Given there is not enough space above or below the anchor to fit the MultiSelect menu + When the MultiSelect is clicked + Then it is rendered on top of the MultiSelect + And the left of the Menu is aligned with the left of the Input + + Scenario: Staying in position when the window is scrolled + Given there is enough space below the anchor to fit the MultiSelect menu + When the MultiSelect is clicked + And the window is scrolled down + Then the top of the menu is aligned with the bottom of the input + And the left of the Menu is aligned with the left of the Input + + Scenario: Adusting the menu width when the window resizes + Given there is enough space below the anchor to fit the MultiSelect menu + When the MultiSelect is clicked + Then the left of the Menu is aligned with the left of the Input + And the Menu and the Input have an equal width + When the window is resized to a greater width + Then the left of the Menu is aligned with the left of the Input + And the Menu and the Input have an equal width + + Scenario: Repositioning the menu when the Input grows + Given a MultiSelect is rendered to which options can be added + And the input is empty + When the MultiSelect is clicked + Then the top of the menu is aligned with the bottom of the input + And the left of the Menu is aligned with the left of the Input + When an option is clicked + Then the Input grows in height + And the top of the menu is aligned with the bottom of the input + And the left of the Menu is aligned with the left of the Input + diff --git a/cypress/integration/MultiSelect/position/index.js b/cypress/integration/MultiSelect/position/index.js new file mode 100644 index 0000000000..4deb612c9e --- /dev/null +++ b/cypress/integration/MultiSelect/position/index.js @@ -0,0 +1,161 @@ +import '../common' +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'there is enough space below the anchor to fit the MultiSelect menu', + () => { + cy.visitStory('MultiSelect', 'Default position') + } +) + +Given( + 'there is not enough space below the anchor to fit the MultiSelect menu', + () => { + cy.visitStory('MultiSelect', 'Flipped position') + } +) + +Given( + 'there is not enough space above or below the anchor to fit the MultiSelect menu', + () => { + cy.visitStory('MultiSelect', 'Shifted into view') + } +) + +Given('the input is empty', () => { + cy.get('[data-test="dhis2-uicore-select-input"]') + .should('exist') + .should('have.length', 1) + .then(inputs => { + const $input = inputs[0] + const inputRect = $input.getBoundingClientRect() + + return inputRect.height + }) + .as('emptyInputHeight') + + cy.get('[data-test="dhis2-uicore-select-input"] .root').should('be.empty') +}) + +When('the MultiSelect is clicked', () => { + cy.get('[data-test="dhis2-uicore-multiselect"]').click() +}) + +When('the window is scrolled down', () => { + cy.scrollTo(0, 800) +}) + +When('the window is resized to a greater width', () => { + cy.viewport(1200, 660) +}) + +When('an option is clicked', () => { + cy.contains('option one').click() +}) + +Then('the Input grows in height', () => { + const emptyInputHeight = '@emptyInputHeight' + const inputDataTest = '[data-test="dhis2-uicore-select-input"]' + + cy.getAll(emptyInputHeight, inputDataTest).should( + ([emptyInputHeight, inputs]) => { + expect(inputs.length).to.equal(1) + + const $input = inputs[0] + const inputRect = $input.getBoundingClientRect() + + expect(inputRect.height).to.be.greaterThan(emptyInputHeight) + } + ) +}) + +Then('the top of the menu is aligned with the bottom of the input', () => { + const selectDataTest = '[data-test="dhis2-uicore-multiselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.getAll(selectDataTest, menuDataTest).should(([selects, menus]) => { + expect(selects.length).to.equal(1) + expect(menus.length).to.equal(1) + + const $select = selects[0] + const $menu = menus[0] + + const selectRect = $select.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(menuRect.top).to.equal(selectRect.bottom) + }) +}) + +Then('the bottom of the menu is aligned with the top of the input', () => { + const selectDataTest = '[data-test="dhis2-uicore-multiselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.getAll(selectDataTest, menuDataTest).should(([selects, menus]) => { + expect(selects.length).to.equal(1) + expect(menus.length).to.equal(1) + + const $select = selects[0] + const $menu = menus[0] + + const selectRect = $select.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(selectRect.top).to.equal(menuRect.bottom) + }) +}) + +Then('it is rendered on top of the MultiSelect', () => { + const selectDataTest = '[data-test="dhis2-uicore-multiselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.getAll(selectDataTest, menuDataTest).should(([selects, menus]) => { + expect(selects.length).to.equal(1) + expect(menus.length).to.equal(1) + + const $select = selects[0] + const $menu = menus[0] + + const selectRect = $select.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(selectRect.top).to.be.greaterThan(menuRect.top) + expect(menuRect.bottom).to.be.greaterThan(selectRect.bottom) + }) +}) + +Then('the left of the Menu is aligned with the left of the Input', () => { + const selectDataTest = '[data-test="dhis2-uicore-multiselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.getAll(selectDataTest, menuDataTest).should(([selects, menus]) => { + expect(selects.length).to.equal(1) + expect(menus.length).to.equal(1) + + const $select = selects[0] + const $menu = menus[0] + + const selectRect = $select.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(selectRect.left).to.equal(menuRect.left) + }) +}) + +Then('the Menu and the Input have an equal width', () => { + const inputDataTest = '[data-test="dhis2-uicore-multiselect"] .root-input' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.getAll(inputDataTest, menuDataTest).should(([inputs, menus]) => { + expect(inputs.length).to.equal(1) + expect(menus.length).to.equal(1) + + const $input = inputs[0] + const $menu = menus[0] + + const inputRect = $input.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(inputRect.width).to.equal(menuRect.width) + }) +}) 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/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..3b3b2f7512 --- /dev/null +++ b/cypress/integration/MultiSelectField/can_be_required/index.js @@ -0,0 +1,11 @@ +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-uiwidgets-multiselectfield-label-required"]' + ).should('be.visible') +}) diff --git a/cypress/integration/MultiSelectField/has_default_clear_text.feature b/cypress/integration/MultiSelectField/has_default_clear_text.feature new file mode 100644 index 0000000000..368887c0e4 --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_clear_text.feature @@ -0,0 +1,5 @@ +Feature: Clear text for the MultiSelectField + + Scenario: Rendering a clearable MultiSelectField + Given a clearable MultiSelectField with selected option is rendered + Then the clear text is visible diff --git a/cypress/integration/MultiSelectField/has_default_clear_text/index.js b/cypress/integration/MultiSelectField/has_default_clear_text/index.js new file mode 100644 index 0000000000..c166b76ca9 --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_clear_text/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a clearable MultiSelectField with selected option is rendered', () => { + cy.visitStory('MultiSelectField', 'With clearable and selected option') +}) + +Then('the clear text is visible', () => { + cy.contains('Clear').should('be.visible') +}) diff --git a/cypress/integration/MultiSelectField/has_default_empty_text.feature b/cypress/integration/MultiSelectField/has_default_empty_text.feature new file mode 100644 index 0000000000..9fc50b7636 --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_empty_text.feature @@ -0,0 +1,6 @@ +Feature: Empty text for the MultiSelectField + + Scenario: Rendering an empty MultiSelectField + Given an empty MultiSelectField is rendered + When the Select is opened + Then the empty text is visible diff --git a/cypress/integration/MultiSelectField/has_default_empty_text/index.js b/cypress/integration/MultiSelectField/has_default_empty_text/index.js new file mode 100644 index 0000000000..6303e807a2 --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_empty_text/index.js @@ -0,0 +1,13 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given('an empty MultiSelectField is rendered', () => { + cy.visitStory('MultiSelectField', 'Without options') +}) + +When('the Select is opened', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +Then('the empty text is visible', () => { + cy.contains('No data found').should('be.visible') +}) diff --git a/cypress/integration/MultiSelectField/has_default_filter_nomatch_text.feature b/cypress/integration/MultiSelectField/has_default_filter_nomatch_text.feature new file mode 100644 index 0000000000..935d0a3ded --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_filter_nomatch_text.feature @@ -0,0 +1,7 @@ +Feature: Nomatchtext for the MultiSelectField + + Scenario: Rendering a filterable MultiSelectField + Given a filterable MultiSelectField is rendered + When the Select is opened + And a filter that does not match any options is entered + Then the no match text is visible diff --git a/cypress/integration/MultiSelectField/has_default_filter_nomatch_text/index.js b/cypress/integration/MultiSelectField/has_default_filter_nomatch_text/index.js new file mode 100644 index 0000000000..b1bfcef13b --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_filter_nomatch_text/index.js @@ -0,0 +1,17 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given('a filterable MultiSelectField is rendered', () => { + cy.visitStory('MultiSelectField', 'With filterable') +}) + +When('the Select is opened', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +When('a filter that does not match any options is entered', () => { + cy.focused().type('Two') +}) + +Then('the no match text is visible', () => { + cy.contains('No options found').should('be.visible') +}) diff --git a/cypress/integration/MultiSelectField/has_default_filter_placeholder.feature b/cypress/integration/MultiSelectField/has_default_filter_placeholder.feature new file mode 100644 index 0000000000..50182d6be4 --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_filter_placeholder.feature @@ -0,0 +1,6 @@ +Feature: Filter placeholder for the MultiSelectField + + Scenario: Rendering a filterable MultiSelectField + Given a filterable MultiSelectField is rendered + When the Select is opened + Then the filter placeholder exists diff --git a/cypress/integration/MultiSelectField/has_default_filter_placeholder/index.js b/cypress/integration/MultiSelectField/has_default_filter_placeholder/index.js new file mode 100644 index 0000000000..2fd35fba80 --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_filter_placeholder/index.js @@ -0,0 +1,15 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given('a filterable MultiSelectField is rendered', () => { + cy.visitStory('MultiSelectField', 'With filterable') +}) + +When('the Select is opened', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +Then('the filter placeholder exists', () => { + cy.get( + '[data-test="dhis2-uicore-multiselect-filterinput"] [placeholder="Type to filter options"]' + ).should('exist') +}) diff --git a/cypress/integration/MultiSelectField/has_default_loading_text.feature b/cypress/integration/MultiSelectField/has_default_loading_text.feature new file mode 100644 index 0000000000..a6c8aa4c00 --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_loading_text.feature @@ -0,0 +1,6 @@ +Feature: Filter placeholder for the MultiSelectField + + Scenario: Rendering a filterable MultiSelectField + Given a loading MultiSelectField is rendered + When the Select is opened + Then the loading text is visible diff --git a/cypress/integration/MultiSelectField/has_default_loading_text/index.js b/cypress/integration/MultiSelectField/has_default_loading_text/index.js new file mode 100644 index 0000000000..79b32c0db6 --- /dev/null +++ b/cypress/integration/MultiSelectField/has_default_loading_text/index.js @@ -0,0 +1,13 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given('a loading MultiSelectField is rendered', () => { + cy.visitStory('MultiSelectField', 'With loading') +}) + +When('the Select is opened', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +Then('the loading text is visible', () => { + cy.contains('Loading options').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..7e895a6d7d --- /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().should(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..24eb5a207c --- /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().should(win => { + expect(win.onOpen).to.be.calledWith({ open: true }) + }) +}) diff --git a/cypress/integration/NoticeBox/accepts_children.feature b/cypress/integration/NoticeBox/accepts_children.feature new file mode 100644 index 0000000000..6fca591ccd --- /dev/null +++ b/cypress/integration/NoticeBox/accepts_children.feature @@ -0,0 +1,5 @@ +Feature: The NoticeBox can render an optional message + + Scenario: A NoticeBox is provided a message + Given a NoticeBox receives a message as children + Then the message is visible diff --git a/cypress/integration/NoticeBox/accepts_children/index.js b/cypress/integration/NoticeBox/accepts_children/index.js new file mode 100644 index 0000000000..c295f86214 --- /dev/null +++ b/cypress/integration/NoticeBox/accepts_children/index.js @@ -0,0 +1,12 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a NoticeBox receives a message as children', () => { + cy.visitStory('NoticeBox', 'With children') + cy.get('[data-test="dhis2-uicore-noticebox"]').should('be.visible') +}) + +Then('the message is visible', () => { + cy.get('[data-test="dhis2-uicore-noticebox-message"]') + .contains('The noticebox content') + .should('be.visible') +}) diff --git a/cypress/integration/NoticeBox/accepts_title.feature b/cypress/integration/NoticeBox/accepts_title.feature new file mode 100644 index 0000000000..cc3f49d09c --- /dev/null +++ b/cypress/integration/NoticeBox/accepts_title.feature @@ -0,0 +1,5 @@ +Feature: The NoticeBox can render an optional title + + Scenario: A NoticeBox is provided a title + Given a NoticeBox receives a title prop + Then the title is visible diff --git a/cypress/integration/NoticeBox/accepts_title/index.js b/cypress/integration/NoticeBox/accepts_title/index.js new file mode 100644 index 0000000000..af4a779f42 --- /dev/null +++ b/cypress/integration/NoticeBox/accepts_title/index.js @@ -0,0 +1,12 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a NoticeBox receives a title prop', () => { + cy.visitStory('NoticeBox', 'With title') + cy.get('[data-test="dhis2-uicore-noticebox"]').should('be.visible') +}) + +Then('the title is visible', () => { + cy.get('[data-test="dhis2-uicore-noticebox-title"]') + .contains('The noticebox title') + .should('be.visible') +}) 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..c335a0baf5 --- /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().should(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().should(win => { + expect(win.selection).to.eql(['/A0000000000']) + }) +}) + +Then('the unit on the second level is selected', () => { + cy.window().should(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' + ) + .should($input => { + expect($input[0].indeterminate).to.be.true + }) +}) + +Then('the root unit is marked as selected', () => { + cy.window().should(win => { + expect(win.selection.includes('/A0000000000')).to.be.true + }) +}) + +Then('the first unit is selected', () => { + cy.window().should(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().should(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..e54242919a --- /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"]').should($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..4cce1aab06 --- /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().should(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().should(win => { + expect(win.onChange).to.be.called + }) +}) + +Then('the payload includes the path of the selected node', () => { + cy.window().should(win => { + expectStubPayloadToEqual(win.onChange, 'path', '/A0000000000') + }) +}) + +Then('the payload includes checked which is set to "true"', () => { + cy.window().should(win => { + expectStubPayloadToEqual(win.onChange, 'checked', true) + }) +}) + +Then('the payload includes all selected nodes', () => { + cy.window().should(win => { + expectStubPayloadToEqual(win.onChange, 'selected', ['/A0000000000']) + }) +}) + +Then('the payload includes checked which is set to "false"', () => { + cy.window().should(win => { + expectStubPayloadToEqual(win.onChange, 'checked', false) + }) +}) + +Then('the onExpand callback gets called', () => { + cy.window().should(win => { + expect(win.onExpand).to.be.called + }) +}) + +Then('the payload includes the path of the expanded node', () => { + cy.window().should(win => { + expectStubPayloadToEqual(win.onExpand, 'path', '/A0000000000') + }) +}) + +Then('the onCollapse callback gets called', () => { + cy.window().should(win => { + expect(win.onCollapse).to.be.called + }) +}) + +Then('the payload includes the path of the collapsed node', () => { + cy.window().should(win => { + expectStubPayloadToEqual(win.onCollapse, 'path', '/A0000000000') + }) +}) + +Then('the onChildrenLoaded callback gets called', () => { + cy.window().should(win => { + expect(win.onChildrenLoaded).to.be.called + }) +}) + +Then("the payload contains the loaded children's data", () => { + cy.window().should(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/clicking_outside.feature b/cypress/integration/Popover/clicking_outside.feature new file mode 100644 index 0000000000..d957e5f6b3 --- /dev/null +++ b/cypress/integration/Popover/clicking_outside.feature @@ -0,0 +1,6 @@ +Feature: Popover clicking outside + + Scenario: Responds to a click outdside the Popover + Given a default Popper is rendered with an onClickOutside handler + When the user clicks outside of the Popover + Then the clickOutside handler is called \ No newline at end of file diff --git a/cypress/integration/Popover/clicking_outside/index.js b/cypress/integration/Popover/clicking_outside/index.js new file mode 100644 index 0000000000..721f7769a3 --- /dev/null +++ b/cypress/integration/Popover/clicking_outside/index.js @@ -0,0 +1,16 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a default Popper is rendered with arrow set to true', () => { + cy.visitStory('Popover', 'Default') +}) +Given('a default Popper is rendered with an onClickOutside handler', () => { + cy.visitStory('Popover', 'With On Click Outside') +}) +When('the user clicks outside of the Popover', () => { + cy.get('[data-test="dhis2-uicore-layer"]').click() +}) +Then('the clickOutside handler is called', () => { + cy.window().should(win => { + expect(win.onClickOutside).to.be.calledOnce + }) +}) diff --git a/cypress/integration/Popover/conditional_arrow.feature b/cypress/integration/Popover/conditional_arrow.feature new file mode 100644 index 0000000000..00ed935cc2 --- /dev/null +++ b/cypress/integration/Popover/conditional_arrow.feature @@ -0,0 +1,9 @@ +Feature: Popover conditional arrow + + Scenario: With arrow + Given a default Popper is rendered with arrow set to true + Then there is an arrow element in the Popover + + Scenario: Without arrow + Given a Popover is rendered with the arrow prop set to false + Then there is no arrow element in the Popover \ No newline at end of file diff --git a/cypress/integration/Popover/conditional_arrow/index.js b/cypress/integration/Popover/conditional_arrow/index.js new file mode 100644 index 0000000000..11815de0c0 --- /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-test="dhis2-uicore-popoverarrow"]').should('exist') +}) +Then('there is no arrow element in the Popover', () => { + cy.get('[data-test="dhis2-uicore-popoverarrow"]').should('not.exist') +}) diff --git a/cypress/integration/Popover/position.feature b/cypress/integration/Popover/position.feature new file mode 100644 index 0000000000..44716de9bd --- /dev/null +++ b/cypress/integration/Popover/position.feature @@ -0,0 +1,35 @@ +Feature: Popover positioning + + Scenario: Spacing + Given there is sufficient space to place the Popover above the reference element + Then there is some space between the anchor and the popover + And the arrow is showing + + Scenario: Default positioning + Given there is sufficient space to place the Popover above the reference element + Then the popover is placed above the reference element + And the horizontal center of the popover is aligned with the horizontal center of the reference element + And the arrow is showing + + Scenario: Flipped vertical + Given there is not enough space between the reference element top and the body top to fit the Popover + And there is sufficient space between the reference element bottom and the body bottom to fit the Popover + Then the popover is placed below the reference element + And the horizontal center of the popover is aligned with the horizontal center of the reference element + And the arrow is showing + + Scenario: On top of the reference element + Given there is not enough space between the reference element top and the body top to fit the Popover + And there is not enough space between the reference element bottom and the body bottom to fit the Popover + But there is sufficient space between the bottom of the reference element and the bottom of the Popover to show the arrow + Then the popover is placed op top of the reference element + And the horizontal center of the popover is aligned with the horizontal center of the reference element + And the arrow is showing + + Scenario: Hiding the arrow + Given there is very little space between the top of the reference element and the top of the body + And there is not enough space between the reference element bottom and the body bottom to fit the Popover + And there is not enough space between the top of the reference element and the top of the Popover to show the arrow + Then the popover is placed op top of the reference element + And the horizontal center of the popover is aligned with the horizontal center of the reference element + And the arrow is hiding diff --git a/cypress/integration/Popover/position/index.js b/cypress/integration/Popover/position/index.js new file mode 100644 index 0000000000..4a64a1630e --- /dev/null +++ b/cypress/integration/Popover/position/index.js @@ -0,0 +1,107 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +// 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 + 8) + }) + } +) + +Then('the arrow is hiding', () => { + cy.get('[data-test="dhis2-uicore-popoverarrow"]').should('not.be.visible') +}) + +Then('the arrow is showing', () => { + cy.get('[data-test="dhis2-uicore-popoverarrow"]').should('be.visible') +}) + +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 + 8).to.equal(refPos.top) + }) +}) + +// helper +function getRefAndPopoverPositions() { + return cy.getPositionsBySelectors( + '[data-test="reference-element"]', + '[data-test="dhis2-uicore-popover"]' + ) +} 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..30c198e9a1 --- /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().should(([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().should(([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().should(([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().should(([refPos, popperPos]) => { + expect(refPos.left).to.equal(popperPos.right) + }) + } +) + +// Horizontal alignments +// *-start +Then('it is horizontally left aligned with the reference element', () => { + getRefAndPopperPositions().should(([refPos, popperPos]) => { + expect(refPos.left).to.equal(popperPos.left) + }) +}) +// * (no suffix) +Then('it is horizontally center aligned with the reference element', () => { + getRefAndPopperPositions().should(([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().should(([refPos, popperPos]) => { + expect(refPos.right).to.equal(popperPos.right) + }) +}) + +// Vertical alignments +// *-start +Then('it is vertically top aligned with the reference element', () => { + getRefAndPopperPositions().should(([refPos, popperPos]) => { + expect(refPos.top).to.equal(popperPos.top) + }) +}) +// * (no suffix) +Then('it is vertically center aligned with the reference element', () => { + getRefAndPopperPositions().should(([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().should(([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..e80957ae5d --- /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().should(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..5ffba991bf --- /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().should(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..3d7ad357f1 --- /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().should(win => { + expect(win.onFocus).to.be.calledWith({ + value: 'default', + name: 'Ex', + checked: true, + }) + }) +}) diff --git a/cypress/integration/SingleSelect/accepts_blur_cb.feature b/cypress/integration/SingleSelect/accepts_blur_cb.feature new file mode 100644 index 0000000000..493b97b198 --- /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 layer + 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..839d71be8e --- /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().should(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..1bc0b70657 --- /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().should(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..6818be45f0 --- /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().should(win => { + expect(win.onChange).to.be.calledOnce + expect(win.onChange).to.be.calledWith({ + selected: '1', + }) + }) +}) + +Then('the onchange handler is not called', () => { + cy.window().should(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..836ad5cd19 --- /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().should(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..36b51bd49d --- /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-layer"]').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..5b705652b0 --- /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 layer to hide the options + Given a SingleSelect with options is rendered + And the SingleSelect is open + When the user clicks the backdrop layer + 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/common/index.js b/cypress/integration/SingleSelect/common/index.js new file mode 100644 index 0000000000..a448377229 --- /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 layer', () => { + cy.get('[data-test="dhis2-uicore-layer"]').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/SingleSelect/duplicate_option_values.feature b/cypress/integration/SingleSelect/duplicate_option_values.feature new file mode 100644 index 0000000000..ada56be850 --- /dev/null +++ b/cypress/integration/SingleSelect/duplicate_option_values.feature @@ -0,0 +1,7 @@ +Feature: Duplicate option values result in a conflict between the input and dropdown UI + + Scenario: The SingleSelect has options with a duplicate value and this value is selected + Given a SingleSelect with options with a duplicate value and this value is selected + And the SingleSelect is open + Then the first option with the selected value is displayed in the input + But both options are highlighted in the dropdown diff --git a/cypress/integration/SingleSelect/duplicate_option_values/index.js b/cypress/integration/SingleSelect/duplicate_option_values/index.js new file mode 100644 index 0000000000..5975467874 --- /dev/null +++ b/cypress/integration/SingleSelect/duplicate_option_values/index.js @@ -0,0 +1,27 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a SingleSelect with options with a duplicate value and this value is selected', + () => { + cy.visitStory('SingleSelect', 'With duplicate selected option values') + } +) +Then( + 'the first option with the selected value is displayed in the input', + () => { + cy.get('[data-test="dhis2-uicore-select-input"]').should( + 'contain', + 'option one' + ) + } +) +Then('both options are highlighted in the dropdown', () => { + cy.get('[data-test="dhis2-uicore-singleselectoption"]') + .contains('option one') + .should('have.class', 'active') + + cy.get('[data-test="dhis2-uicore-singleselectoption"]') + .contains('option one a') + .should('have.class', 'active') +}) diff --git a/cypress/integration/SingleSelect/position.feature b/cypress/integration/SingleSelect/position.feature new file mode 100644 index 0000000000..791f229299 --- /dev/null +++ b/cypress/integration/SingleSelect/position.feature @@ -0,0 +1,26 @@ +Feature: Position of SingleSelect menu dropdown + + Scenario: Default rendering + Given there is enough space below the anchor to fit the SingleSelect menu + When the SingleSelect is clicked + Then the top of the menu is aligned with the bottom of the input + And the left of the SingleSelect is aligned with the left of the anchor + + Scenario: Flipped rendering when insufficient space below + Given there is not enough space below the anchor to fit the SingleSelect menu + When the SingleSelect is clicked + Then the bottom of the menu is aligned with the top of the input + And the left of the SingleSelect is aligned with the left of the anchor + + Scenario: Shifting into view when insufficient space below and above + Given there is not enough space above or below the anchor to fit the SingleSelect menu + When the SingleSelect is clicked + Then it is rendered on top of the SingleSelect + And the left of the SingleSelect is aligned with the left of the anchor + + Scenario: Staying in position during when the window is scrolled + Given there is enough space below the anchor to fit the SingleSelect menu + When the SingleSelect is clicked + And the window is scrolled down + Then the top of the menu is aligned with the bottom of the input + And the left of the SingleSelect is aligned with the left of the anchor diff --git a/cypress/integration/SingleSelect/position/index.js b/cypress/integration/SingleSelect/position/index.js new file mode 100644 index 0000000000..19175d0cff --- /dev/null +++ b/cypress/integration/SingleSelect/position/index.js @@ -0,0 +1,107 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'there is enough space below the anchor to fit the SingleSelect menu', + () => { + cy.visitStory('SingleSelect', 'Default position') + } +) + +Given( + 'there is not enough space below the anchor to fit the SingleSelect menu', + () => { + cy.visitStory('SingleSelect', 'Flipped position') + } +) + +Given( + 'there is not enough space above or below the anchor to fit the SingleSelect menu', + () => { + cy.visitStory('SingleSelect', 'Shifted into view') + } +) + +When('the SingleSelect is clicked', () => { + cy.get('[data-test="dhis2-uicore-singleselect"]').click() +}) + +When('the window is scrolled down', () => { + cy.scrollTo(0, 800) +}) + +Then('the top of the menu is aligned with the bottom of the input', () => { + const selectDataTest = '[data-test="dhis2-uicore-singleselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.getAll(selectDataTest, menuDataTest).should(([selects, menus]) => { + expect(selects.length).to.equal(1) + expect(menus.length).to.equal(1) + + const $select = selects[0] + const $menu = menus[0] + + const selectRect = $select.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(menuRect.top).to.equal(selectRect.bottom) + }) +}) + +Then('the bottom of the menu is aligned with the top of the input', () => { + const selectDataTest = '[data-test="dhis2-uicore-singleselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.getAll(selectDataTest, menuDataTest).should(([selects, menus]) => { + expect(selects.length).to.equal(1) + expect(menus.length).to.equal(1) + + const $select = selects[0] + const $menu = menus[0] + + const selectRect = $select.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(selectRect.top).to.equal(menuRect.bottom) + }) +}) + +Then('it is rendered on top of the SingleSelect', () => { + const selectDataTest = '[data-test="dhis2-uicore-singleselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.getAll(selectDataTest, menuDataTest).should(([selects, menus]) => { + expect(selects.length).to.equal(1) + expect(menus.length).to.equal(1) + + const $select = selects[0] + const $menu = menus[0] + + const selectRect = $select.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(selectRect.top).to.be.greaterThan(menuRect.top) + expect(menuRect.bottom).to.be.greaterThan(selectRect.bottom) + }) +}) + +Then( + 'the left of the SingleSelect is aligned with the left of the anchor', + () => { + const selectDataTest = '[data-test="dhis2-uicore-singleselect"]' + const menuDataTest = + '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.getAll(selectDataTest, menuDataTest).should(([selects, menus]) => { + expect(selects.length).to.equal(1) + expect(menus.length).to.equal(1) + + const $select = selects[0] + const $menu = menus[0] + + const selectRect = $select.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(selectRect.left).to.equal(menuRect.left) + }) + } +) 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/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..9a2ae065cb --- /dev/null +++ b/cypress/integration/SingleSelectField/can_be_required/index.js @@ -0,0 +1,11 @@ +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-uiwidgets-singleselectfield-label-required"]' + ).should('be.visible') +}) diff --git a/cypress/integration/SingleSelectField/has_default_clear_text.feature b/cypress/integration/SingleSelectField/has_default_clear_text.feature new file mode 100644 index 0000000000..30518cc930 --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_clear_text.feature @@ -0,0 +1,5 @@ +Feature: Clear text for the SingleSelectField + + Scenario: Rendering a clearable SingleSelectField + Given a clearable SingleSelectField with selected option is rendered + Then the clear text is visible diff --git a/cypress/integration/SingleSelectField/has_default_clear_text/index.js b/cypress/integration/SingleSelectField/has_default_clear_text/index.js new file mode 100644 index 0000000000..b5fb83fd06 --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_clear_text/index.js @@ -0,0 +1,9 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('a clearable SingleSelectField with selected option is rendered', () => { + cy.visitStory('SingleSelectField', 'With clearable and selected option') +}) + +Then('the clear text is visible', () => { + cy.contains('Clear').should('be.visible') +}) diff --git a/cypress/integration/SingleSelectField/has_default_empty_text.feature b/cypress/integration/SingleSelectField/has_default_empty_text.feature new file mode 100644 index 0000000000..e6e623af61 --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_empty_text.feature @@ -0,0 +1,6 @@ +Feature: Empty text for the SingleSelectField + + Scenario: Rendering an empty SingleSelectField + Given an empty SingleSelectField is rendered + When the Select is opened + Then the empty text is visible diff --git a/cypress/integration/SingleSelectField/has_default_empty_text/index.js b/cypress/integration/SingleSelectField/has_default_empty_text/index.js new file mode 100644 index 0000000000..1b56e9da9d --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_empty_text/index.js @@ -0,0 +1,13 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given('an empty SingleSelectField is rendered', () => { + cy.visitStory('SingleSelectField', 'Without options') +}) + +When('the Select is opened', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +Then('the empty text is visible', () => { + cy.contains('No data found').should('be.visible') +}) diff --git a/cypress/integration/SingleSelectField/has_default_filter_nomatch_text.feature b/cypress/integration/SingleSelectField/has_default_filter_nomatch_text.feature new file mode 100644 index 0000000000..1738fd45bd --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_filter_nomatch_text.feature @@ -0,0 +1,7 @@ +Feature: Nomatchtext for the SingleSelectField + + Scenario: Rendering a filterable SingleSelectField + Given a filterable SingleSelectField is rendered + When the Select is opened + And a filter that does not match any options is entered + Then the no match text is visible diff --git a/cypress/integration/SingleSelectField/has_default_filter_nomatch_text/index.js b/cypress/integration/SingleSelectField/has_default_filter_nomatch_text/index.js new file mode 100644 index 0000000000..177205040e --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_filter_nomatch_text/index.js @@ -0,0 +1,17 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given('a filterable SingleSelectField is rendered', () => { + cy.visitStory('SingleSelectField', 'With filterable') +}) + +When('the Select is opened', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +When('a filter that does not match any options is entered', () => { + cy.focused().type('Two') +}) + +Then('the no match text is visible', () => { + cy.contains('No options found').should('be.visible') +}) diff --git a/cypress/integration/SingleSelectField/has_default_filter_placeholder.feature b/cypress/integration/SingleSelectField/has_default_filter_placeholder.feature new file mode 100644 index 0000000000..db13d45cc8 --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_filter_placeholder.feature @@ -0,0 +1,6 @@ +Feature: Filter placeholder for the SingleSelectField + + Scenario: Rendering a filterable SingleSelectField + Given a filterable SingleSelectField is rendered + When the Select is opened + Then the filter placeholder exists diff --git a/cypress/integration/SingleSelectField/has_default_filter_placeholder/index.js b/cypress/integration/SingleSelectField/has_default_filter_placeholder/index.js new file mode 100644 index 0000000000..b843348fa3 --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_filter_placeholder/index.js @@ -0,0 +1,15 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given('a filterable SingleSelectField is rendered', () => { + cy.visitStory('SingleSelectField', 'With filterable') +}) + +When('the Select is opened', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +Then('the filter placeholder exists', () => { + cy.get( + '[data-test="dhis2-uicore-singleselect-filterinput"] [placeholder="Type to filter options"]' + ).should('exist') +}) diff --git a/cypress/integration/SingleSelectField/has_default_loading_text.feature b/cypress/integration/SingleSelectField/has_default_loading_text.feature new file mode 100644 index 0000000000..3a8167a3f0 --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_loading_text.feature @@ -0,0 +1,6 @@ +Feature: Filter placeholder for the SingleSelectField + + Scenario: Rendering a filterable SingleSelectField + Given a loading SingleSelectField is rendered + When the Select is opened + Then the loading text is visible diff --git a/cypress/integration/SingleSelectField/has_default_loading_text/index.js b/cypress/integration/SingleSelectField/has_default_loading_text/index.js new file mode 100644 index 0000000000..faef236e2c --- /dev/null +++ b/cypress/integration/SingleSelectField/has_default_loading_text/index.js @@ -0,0 +1,13 @@ +import { Given, Then, When } from 'cypress-cucumber-preprocessor/steps' + +Given('a loading SingleSelectField is rendered', () => { + cy.visitStory('SingleSelectField', 'With loading') +}) + +When('the Select is opened', () => { + cy.get('[data-test="dhis2-uicore-select-input"]').click() +}) + +Then('the loading text is visible', () => { + cy.contains('Loading options').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..1c487b574a --- /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 user clicks the backdrop Layer + 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..e53edb8c7c --- /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 user clicks the backdrop Layer', () => { + cy.get('[data-test="dhis2-uicore-layer"]').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..f4da0546d6 --- /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().should(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..fb9b5390e9 --- /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().should(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..cb5a6dff4a --- /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().should(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..5fbef9dc13 --- /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-uiwidgets-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..3194828744 --- /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().should(win => { + expect(win.onClick).to.be.calledWith({}) + }) +}) + +Then('the onClick handler is not called', () => { + cy.window().should(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..2301127665 --- /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().should(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..3e6cd98356 --- /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().should(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..2cff6bf691 --- /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().should(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..cf3a0cc016 --- /dev/null +++ b/cypress/integration/TextAreaField/can_be_required/index.js @@ -0,0 +1,11 @@ +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-uiwidgets-textareafield-label-required"]').should( + 'be.visible' + ) +}) diff --git a/cypress/integration/Tooltip/common/index.js b/cypress/integration/Tooltip/common/index.js new file mode 100644 index 0000000000..a2f439e406 --- /dev/null +++ b/cypress/integration/Tooltip/common/index.js @@ -0,0 +1,47 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'there is enough space between the reference element top and the body top to fit the Tooltip', + () => { + cy.visitStory('Tooltip', 'Default') + } +) + +Given( + 'there is not enough space above or below the anchor to fit the Tooltip', + () => { + cy.visitStory('Tooltip', 'Shifting Position') + } +) + +When('the mouse cursor enters the anchor', () => { + cy.get('[data-test="dhis2-uicore-tooltip-reference"]').trigger( + 'mouseover', + 'top' + ) +}) + +Then('the Tooltip is rendered above the anchor', () => { + cy.getPositionsBySelectors( + '[data-test="dhis2-uicore-tooltip-reference"]', + '[data-test="dhis2-uicore-tooltip-content"]' + ).should(([refPos, contentPos]) => { + expect(refPos.top).to.be.greaterThan(contentPos.bottom) + }) +}) + +Then('the Tooltip stays visible', () => { + cy.clock() + cy.tick(800) + cy.get('[data-test="dhis2-uicore-tooltip-content"]').should('exist') +}) + +Then('the Tooltip is rendered on top of the anchor', () => { + cy.getPositionsBySelectors( + '[data-test="dhis2-uicore-tooltip-reference"]', + '[data-test="dhis2-uicore-tooltip-content"]' + ).should(([refPos, contentPos]) => { + expect(contentPos.bottom).to.be.greaterThan(refPos.top) + expect(refPos.top).to.be.greaterThan(contentPos.top) + }) +}) diff --git a/cypress/integration/Tooltip/positions.feature b/cypress/integration/Tooltip/positions.feature new file mode 100644 index 0000000000..1035b1ecab --- /dev/null +++ b/cypress/integration/Tooltip/positions.feature @@ -0,0 +1,29 @@ +Feature: Tooltip positioning + + Scenario: Default positioning + Given there is enough space between the reference element top and the body top to fit the Tooltip + When the mouse cursor enters the anchor + Then the Tooltip is rendered above the anchor + And the horizontal center of the Tooltip is aligned with the horizontal center of the anchor + And there is some space between the anchor top and the Tooltip bottom + + Scenario: Flipped vertical + Given there is not enough space between the reference element top and the body top to fit the Tooltip + But there is enough space between the anchor bottom and the body bottom to fit the Tooltip + When the mouse cursor enters the anchor + Then the Tooltip is rendered below the anchor + And the horizontal center of the Tooltip is aligned with the horizontal center of the anchor + And there is some space between the anchor bottom and the Tooltip top + + Scenario: Adjusted horizontal position + Given there is limited space available to the left of the anchor + When the mouse cursor enters the anchor + Then the Tooltip is rendered above the anchor + And the horizontal center of the Tooltip is to the right of the horizontal center of the anchor + And there is some space between the anchor top and the Tooltip bottom + + Scenario: Shifting position + Given there is not enough space above or below the anchor to fit the Tooltip + When the mouse cursor enters the anchor + Then the Tooltip is rendered on top of the anchor + And the Tooltip stays visible diff --git a/cypress/integration/Tooltip/positions/index.js b/cypress/integration/Tooltip/positions/index.js new file mode 100644 index 0000000000..f6fed3dfa1 --- /dev/null +++ b/cypress/integration/Tooltip/positions/index.js @@ -0,0 +1,89 @@ +import '../common' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +const TOOLTIP_OFFSET = 4 +const TOOLTIP_HEIGHT = 28 + +// Stories +Given( + 'there is not enough space between the reference element top and the body top to fit the Tooltip', + () => { + cy.visitStory('Tooltip', 'Flipped Vertically') + } +) +Given( + 'there is enough space between the anchor bottom and the body bottom to fit the Tooltip', + () => { + getReferenceAndBodyPositions().should(([refPos, bodyPos]) => { + expect(bodyPos.bottom).to.be.greaterThan( + refPos.bottom + TOOLTIP_OFFSET + TOOLTIP_HEIGHT + ) + }) + } +) + +Given('there is limited space available to the left of the anchor', () => { + cy.visitStory('Tooltip', 'Adjusted Horizontal Position') +}) + +Then( + 'the horizontal center of the Tooltip is aligned with the horizontal center of the anchor', + () => { + getReferenceAndContentPositions().should(([refPos, contentPos]) => { + const refCenterX = refPos.left + refPos.width / 2 + const contentCenterX = contentPos.left + contentPos.width / 2 + + expect(refCenterX).to.be.approximately(contentCenterX, 0.5) + }) + } +) + +Then( + 'the horizontal center of the Tooltip is to the right of the horizontal center of the anchor', + () => { + getReferenceAndContentPositions().should(([refPos, contentPos]) => { + const contentCenterX = contentPos.left + contentPos.width / 2 + const refCenterX = refPos.left + refPos.width / 2 + + expect(contentCenterX).to.be.greaterThan(refCenterX) + }) + } +) + +Then( + 'there is some space between the anchor bottom and the Tooltip top', + () => { + getReferenceAndContentPositions().should(([refPos, contentPos]) => { + expect(refPos.bottom + TOOLTIP_OFFSET).to.equal(contentPos.top) + }) + } +) + +Then( + 'there is some space between the anchor top and the Tooltip bottom', + () => { + getReferenceAndContentPositions().should(([refPos, contentPos]) => { + expect(refPos.top).to.equal(contentPos.bottom + TOOLTIP_OFFSET) + }) + } +) + +Then('the Tooltip is rendered below the anchor', () => { + getReferenceAndContentPositions().should(([refPos, contentPos]) => { + expect(contentPos.top).to.be.greaterThan(refPos.bottom) + }) +}) + +// helper +function getReferenceAndBodyPositions() { + return cy.getPositionsBySelectors( + '[data-test="dhis2-uicore-tooltip-reference"]', + 'body' + ) +} +function getReferenceAndContentPositions() { + return cy.getPositionsBySelectors( + '[data-test="dhis2-uicore-tooltip-reference"]', + '[data-test="dhis2-uicore-tooltip-content"]' + ) +} diff --git a/cypress/integration/Tooltip/visibility_toggling.feature b/cypress/integration/Tooltip/visibility_toggling.feature new file mode 100644 index 0000000000..048c41a09b --- /dev/null +++ b/cypress/integration/Tooltip/visibility_toggling.feature @@ -0,0 +1,54 @@ +Feature: Tooltip visibility toggling + + Scenario: Showing + Given there is enough space between the reference element top and the body top to fit the Tooltip + When the mouse cursor enters the anchor + Then the Tooltip is rendered above the anchor + And the Tooltip stays visible + + Scenario: Prevent showing on quick mouseout + Given there is enough space between the reference element top and the body top to fit the Tooltip + When the mouse cursor enters the anchor + When the mouse cursor leaves the anchor + And the Tooltip is not rendered + + Scenario: Hiding + Given there is enough space between the reference element top and the body top to fit the Tooltip + When the mouse cursor enters the anchor + Then the Tooltip is rendered above the anchor + When the mouse cursor leaves the anchor + Then the Tooltip is not rendered + + Scenario: Prevent hiding on re-enter + Given there is enough space between the reference element top and the body top to fit the Tooltip + When the mouse cursor enters the anchor + Then the Tooltip is rendered above the anchor + When the mouse cursor leaves the anchor + Then there is a short pauze + When the mouse cursor enters the anchor + Then the Tooltip is rendered above the anchor + And the Tooltip stays visible + + Scenario: Hiding after re-enter + Given there is enough space between the reference element top and the body top to fit the Tooltip + When the mouse cursor enters the anchor + Then the Tooltip is rendered above the anchor + When the mouse cursor leaves the anchor + And there is a short pauze + When the mouse cursor enters the anchor + Then the Tooltip is rendered above the anchor + And the Tooltip stays visible + When the mouse cursor leaves the anchor + Then the Tooltip is not rendered + + Scenario: Showing when Tooltip is on top of element + Given there is not enough space above or below the anchor to fit the Tooltip + When the mouse cursor enters the anchor + Then the Tooltip is rendered on top of the anchor + And the Tooltip stays visible + + Scenario: Hiding when Tooltip is on top of element + Given there is not enough space above or below the anchor to fit the Tooltip + When the mouse cursor enters the anchor + And the mouse cursor leaves the anchor + Then the Tooltip is not rendered diff --git a/cypress/integration/Tooltip/visibility_toggling/index.js b/cypress/integration/Tooltip/visibility_toggling/index.js new file mode 100644 index 0000000000..861f0c87df --- /dev/null +++ b/cypress/integration/Tooltip/visibility_toggling/index.js @@ -0,0 +1,21 @@ +import '../common' +import { When, Then } from 'cypress-cucumber-preprocessor/steps' + +When('the mouse cursor leaves the anchor', () => { + // Trigger the mouseout event, and make sure the mouse is + // actually out of the reference element rect + cy.get('[data-test="dhis2-uicore-tooltip-reference"]').trigger('mouseout', { + clientX: 800, + clientY: 800, + }) +}) + +Then('the Tooltip is not rendered', () => { + cy.get('[data-test="dhis2-uicore-tooltip-content"]').should('not.exist') +}) + +When('there is a short pauze', () => { + cy.clock() + // Shorter than the components CLOSE_DELAY + cy.tick(300) +}) diff --git a/cypress/integration/Transfer/add_remove-highlighted-options.feature b/cypress/integration/Transfer/add_remove-highlighted-options.feature new file mode 100644 index 0000000000..57effa0b0b --- /dev/null +++ b/cypress/integration/Transfer/add_remove-highlighted-options.feature @@ -0,0 +1,41 @@ +Feature: Add/Remove options to/from the highlighted options + + Scenario Outline: Highlight multiple items using CMD/CTRL+click + Given the list has two or more items + When the user clicks multiple items with "" which are not highlighted + Then all of the clicked items should be highlighted + + Examples: + | type | metaKey | + | option | cmd | + | option | ctrl | + | selected | cmd | + | selected | ctrl | + + Scenario Outline: Unhighlight items using CMD/CTRL+click + Given the list has two or more items + And some items are highlighted + When the user clicks on one item with "" which is highlighted + Then the clicked item should not be highlighted + And the other previously highlighted items should remain highlighted + + Examples: + | type | metaKey | + | option | cmd | + | option | ctrl | + | selected | cmd | + | selected | ctrl | + + Scenario Outline: A user clicks a highlighted option without a modifier key when multiple options are highlighted + Given the list has two or more items + And some items are highlighted + When the user clicks on one item without a modifier key which is highlighted + Then the clicked option is highlighted + And the other previously highlighted items should not be highlighted + + Examples: + | type | metaKey | + | option | cmd | + | option | ctrl | + | selected | cmd | + | selected | ctrl | diff --git a/cypress/integration/Transfer/add_remove-highlighted-options/index.js b/cypress/integration/Transfer/add_remove-highlighted-options/index.js new file mode 100644 index 0000000000..8ea1bad816 --- /dev/null +++ b/cypress/integration/Transfer/add_remove-highlighted-options/index.js @@ -0,0 +1,92 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the option list has two or more items', () => { + cy.visitStory('Transfer add & remove highlighted options', 'Has Options') + cy.get('{transfer-sourceoptions}').as('list') +}) + +Given('the selected list has two or more items', () => { + cy.visitStory('Transfer add & remove highlighted options', 'Has Selected') + cy.get('{transfer-pickedoptions}').as('list') +}) + +Given('some items are highlighted', () => { + cy.get('@list') + .find('{transferoption}') + .then($options => { + const multipleOptions = $options.filter(index => index < 3) + return cy.wrap(multipleOptions) + }) + .as('highlightedMultipleOptions') + .each($option => cy.wrap($option).clickWith('ctrl')) +}) + +When( + 'the user clicks multiple items with {string} which are not highlighted', + modifierKey => { + cy.get('@list') + .find('{transferoption}') + .then($options => { + const multipleOptions = $options.filter(index => index < 3) + return cy.wrap(multipleOptions) + }) + .as('multipleOptions') + .each($option => + cy + .wrap($option) + .clickWith(modifierKey === 'cmd' ? 'meta' : modifierKey) + ) + } +) + +When( + 'the user clicks on one item with {string} which is highlighted', + modifierKey => { + cy.get('@highlightedMultipleOptions') + .first() + .clickWith(modifierKey === 'cmd' ? 'meta' : modifierKey) + .as('clickedOption') + cy.get('@highlightedMultipleOptions') + .filter(index => index !== 0) + .as('remainingHighlightedOptions') + } +) + +When( + 'the user clicks on one item without a modifier key which is highlighted', + () => { + cy.get('@highlightedMultipleOptions') + .first() + .click() + .as('clickedOption') + cy.get('@highlightedMultipleOptions') + .filter(index => index !== 0) + .as('remainingHighlightedOptions') + } +) + +Then('all of the clicked items should be highlighted', () => { + cy.get('@multipleOptions').each($option => { + expect($option).to.have.class('highlighted') + }) +}) + +Then('the clicked item should not be highlighted', () => { + cy.get('@clickedOption').should('not.have.class', 'highlighted') +}) + +Then('the other previously highlighted items should remain highlighted', () => { + cy.get('@remainingHighlightedOptions').each($option => { + expect($option).to.have.class('highlighted') + }) +}) + +Then('the clicked option is highlighted', () => { + cy.get('@clickedOption').should('have.class', 'highlighted') +}) + +Then('the other previously highlighted items should not be highlighted', () => { + cy.get('@remainingHighlightedOptions').each($option => { + expect($option).to.not.have.class('highlighted') + }) +}) diff --git a/cypress/integration/Transfer/common/index.js b/cypress/integration/Transfer/common/index.js new file mode 100644 index 0000000000..bd8528b701 --- /dev/null +++ b/cypress/integration/Transfer/common/index.js @@ -0,0 +1,8 @@ +export const extractOptionFromElement = element => { + const $element = Cypress.$(element) + + return { + label: $element.text(), + value: $element.data('value'), + } +} diff --git a/cypress/integration/Transfer/disabled-transfer-buttons.feature b/cypress/integration/Transfer/disabled-transfer-buttons.feature new file mode 100644 index 0000000000..caeb6e4e15 --- /dev/null +++ b/cypress/integration/Transfer/disabled-transfer-buttons.feature @@ -0,0 +1,46 @@ +@component-transfer @button-states +Feature: Disable transfer buttons when actions are not possible + + Scenario: None of the selectable options are highlighted + Given the options list has items + And no option items are highlighted + Then the 'move to picked list' button should be disabled + + Scenario: Some of the selectable options are highlighted + Given the options list has items + And some option items are highlighted + Then the 'move to picked list' button should be enabled + + Scenario: None of the selected options are highlighted + Given the selected list has items + And no selected items are highlighted + Then the 'move to source list' button should be disabled + + Scenario: Some of the selected options are highlighted + Given the selected list has items + And some selected items are highlighted + Then the 'move to source list' button should be enabled + + Scenario: The transfer does not have any options at all + Given the transfer does not have any options + Then the 'move all to picked list' button should be disabled + + Scenario: All options are on the selected side + Given all options have been selected + Then the 'move all to picked list' button should be disabled + + Scenario: Some items in options list + Given the options list has items + Then the 'move all to picked list' button should be enabled + + Scenario: No items in selected list + Given the selected list does not have items + Then the 'move all to source list' button should be disabled + + Scenario: Some items in selected list + Given the selected list has items + Then the 'move all to source list' button should be enabled + + Scenario: Only disabled options in source list + Given a source list that has only disabled options + Then the 'move all to picked list' button should be disabled diff --git a/cypress/integration/Transfer/disabled-transfer-buttons/index.js b/cypress/integration/Transfer/disabled-transfer-buttons/index.js new file mode 100644 index 0000000000..26ca7e85a4 --- /dev/null +++ b/cypress/integration/Transfer/disabled-transfer-buttons/index.js @@ -0,0 +1,118 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the options list has items', () => { + cy.visitStory('Transfer Disabled Transfer Buttons', 'Has Options') +}) + +Given('the selected list has items', () => { + cy.visitStory('Transfer Disabled Transfer Buttons', 'Some Options Selected') +}) + +Given('the transfer does not have any options', () => { + cy.visitStory('Transfer Disabled Transfer Buttons', 'No Options') +}) + +Given('all options have been selected', () => { + cy.visitStory('Transfer Disabled Transfer Buttons', 'All Options Selected') +}) + +Given('the selected list does not have items', () => { + cy.visitStory('Transfer Disabled Transfer Buttons', 'Has Options') +}) + +Given('the selected list has items', () => { + cy.visitStory('Transfer Disabled Transfer Buttons', 'Some Options Selected') +}) + +Given('no option items are highlighted', () => { + cy.get( + '[data-test="dhis2-uicore-transfer-sourceoptions"] [data-test="dhis2-uicore-transferoption"]' + ).each($option => cy.wrap($option).should('not.have.class', 'highlighted')) +}) + +Given('some option items are highlighted', () => { + cy.get( + '[data-test="dhis2-uicore-transfer-sourceoptions"] [data-test="dhis2-uicore-transferoption"]' + ) + .first() + .click() +}) + +Given('no selected items are highlighted', () => { + cy.get( + '[data-test="dhis2-uicore-transfer-pickedoptions"] [data-test="dhis2-uicore-transferoption"]' + ).each($option => cy.wrap($option).should('not.have.class', 'highlighted')) +}) + +Given('some selected items are highlighted', () => { + cy.get( + '[data-test="dhis2-uicore-transfer-pickedoptions"] [data-test="dhis2-uicore-transferoption"]' + ) + .first() + .click() +}) + +Given('a source list that has only disabled options', () => { + cy.visitStory( + 'Transfer Disabled Transfer Buttons', + 'Only Disabled Source Options' + ) +}) + +Then("the 'move to picked list' button should be disabled", () => { + cy.get( + '[data-test="dhis2-uicore-transfer-actions-addindividual"][disabled]' + ).should('exist') +}) + +Then("the 'move to picked list' button should be enabled", () => { + cy.get('[data-test="dhis2-uicore-transfer-actions-addindividual"]').should( + 'exist' + ) + cy.get( + '[data-test="dhis2-uicore-transfer-actions-addindividual"][disabled]' + ).should('not.exist') +}) + +Then("the 'move to source list' button should be disabled", () => { + cy.get( + '[data-test="dhis2-uicore-transfer-actions-removeindividual"][disabled]' + ).should('exist') +}) + +Then("the 'move to source list' button should be enabled", () => { + cy.get( + '[data-test="dhis2-uicore-transfer-actions-removeindividual"]' + ).should('exist') + cy.get( + '[data-test="dhis2-uicore-transfer-actions-removeindividual"][disabled]' + ).should('not.exist') +}) + +Then("the 'move all to picked list' button should be disabled", () => { + cy.get( + '[data-test="dhis2-uicore-transfer-actions-addall"][disabled]' + ).should('exist') +}) + +Then("the 'move all to picked list' button should be enabled", () => { + cy.get('[data-test="dhis2-uicore-transfer-actions-addall"]').should('exist') + cy.get( + '[data-test="dhis2-uicore-transfer-actions-addall"][disabled]' + ).should('not.exist') +}) + +Then("the 'move all to source list' button should be disabled", () => { + cy.get( + '[data-test="dhis2-uicore-transfer-actions-removeall"][disabled]' + ).should('exist') +}) + +Then("the 'move all to source list' button should be enabled", () => { + cy.get('[data-test="dhis2-uicore-transfer-actions-removeall"]').should( + 'exist' + ) + cy.get( + '[data-test="dhis2-uicore-transfer-actions-removeall"][disabled]' + ).should('not.exist') +}) diff --git a/cypress/integration/Transfer/disabled-transfer-options.feature b/cypress/integration/Transfer/disabled-transfer-options.feature new file mode 100644 index 0000000000..97389870a7 --- /dev/null +++ b/cypress/integration/Transfer/disabled-transfer-options.feature @@ -0,0 +1,42 @@ +@component-transfer @disabled-options +Feature: Options can be disabled + + Scenario: The user clicks a disabled option + Given a source list that contains a disabled option + When the user clicks a disabled option + Then the disabled option is not highlighted + + Scenario: The user double clicks a disabled option + Given a source list that contains a disabled option + When the user double clicks a disabled option + Then the disabled option is not transferred to the picked list + + Scenario: The user clicks the 'move all to picked list' button with disabled options in the source list + Given a source list that contains at least one disabled option and at least one enabled option + When the user clicks the 'move all to picked list' button + Then the disabled options are not transferred to the picked list + And the enabled options are transferred to the picked list + + Scenario Outline: The user CMD/CTRL+clicks a disabled item with other items highlighted + Given a source list that contains at least one disabled option and at least one enabled highlighted option + When the user clicks the disabled item with "" + Then the disabled option is not highlighted + And the previously highlighted items are still highlighted + + Examples: + | type | + | ctrl | + | cmd | + + Scenario: The user SHIFT+clicks a disabled item with other items highlighted + Given a source list that contains at least one disabled option and at least one enabled highlighted option + When the user SHIFT+clicks the disabled item + Then the disabled option is not highlighted + And only the previously highlighted items are highlighted + + Scenario: The user SHIFT+clicks to select a range of options that include a disabled option + Given a source list that contains at least one disabled option and several enabled options + When the user clicks an enabled option + And SHIFT+clicks another enabled option, including a disabled item in the range of options + Then the enabled options in the range are highlighted + And the disabled option is not highlighted diff --git a/cypress/integration/Transfer/disabled-transfer-options/index.js b/cypress/integration/Transfer/disabled-transfer-options/index.js new file mode 100644 index 0000000000..34dcabb0b0 --- /dev/null +++ b/cypress/integration/Transfer/disabled-transfer-options/index.js @@ -0,0 +1,191 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +import { extractOptionFromElement } from '../common' + +const enabledSourceOptionSelector = + '{transfer-sourceoptions} {transferoption}:not(.disabled)' + +const disabledSourceOptionSelector = + '{transfer-sourceoptions} {transferoption}.disabled' + +Given('a source list that contains a disabled option', () => { + cy.visitStory('Disabled Source Options', 'One Disabled') + cy.get(disabledSourceOptionSelector) + .first() + .as('disabledSourceOption') + .should('exist') +}) + +Given( + 'a source list that contains at least one disabled option and at least one enabled option', + () => { + cy.visitStory('Disabled Source Options', 'One Disabled') + cy.get(disabledSourceOptionSelector) + .first() + .as('disabledSourceOption') + .should('exist') + cy.get(enabledSourceOptionSelector) + .as('enabledSourceOptions') + .should('exist') + } +) + +Given( + 'a source list that contains at least one disabled option and at least one enabled highlighted option', + () => { + cy.visitStory('Disabled Source Options', 'One Disabled') + cy.get(disabledSourceOptionSelector) + .first() + .as('disabledSourceOption') + .should('exist') + cy.get(enabledSourceOptionSelector) + .invoke('slice', 0, 2) + .as('enabledHighlightedSourceOptions') + .each($option => cy.wrap($option).click()) + .should('have.class', 'highlighted') + } +) + +Given( + 'a source list that contains at least one disabled option and several enabled options', + () => { + cy.visitStory('Disabled Source Options', 'One Disabled') + cy.get(disabledSourceOptionSelector) + .first() + .as('disabledSourceOption') + .should('exist') + cy.get(enabledSourceOptionSelector) + .as('enabledSourceOptions') + .should('have.length.of.at.least', 2) + } +) + +When('the user clicks a disabled option', () => { + cy.get(disabledSourceOptionSelector) + .click() + .as('clickedDisabledOption') +}) + +When('the user double clicks a disabled option', () => { + cy.get(disabledSourceOptionSelector) + .dblclick() + .as('doubleClickedDisabledOption') +}) + +When("the user clicks the 'move all to picked list' button", () => { + cy.get('{transfer-actions-addall}').click() +}) + +When('the user clicks the disabled item with {string}', type => { + cy.get(disabledSourceOptionSelector) + .clickWith(type) + .as('clickedWithModifierDisabledOption') +}) + +When('the user SHIFT+clicks the disabled item', () => { + cy.get(disabledSourceOptionSelector) + .clickWith('shift') + .as('clickedWithShiftDisabledOption') +}) + +When('the user clicks an enabled option', () => { + cy.get(enabledSourceOptionSelector) + .first() + .click() + .as('clickedEnabledOption') +}) + +When( + 'SHIFT+clicks another enabled option, including a disabled item in the range of options', + () => { + cy.get(disabledSourceOptionSelector) + .next() + .clickWith('shift') + .as('clickedWithShiftEnabledOption') + } +) + +Then('the disabled option is not highlighted', () => { + cy.get('@disabledSourceOption').should('not.have.class', 'highlighted') +}) + +Then('the disabled option is not transferred to the picked list', () => { + // if the aliased element still exists, it hasn't been removed + // from the source list. Transferring does not need to be tested + // here as that's covered by another test + cy.get('@disabledSourceOption').should('exist') +}) + +Then('the disabled options are not transferred to the picked list', () => { + // if the aliased element still exists, it hasn't been removed + // from the source list. Transferring does not need to be tested + // here as that's covered by another test + cy.get('@disabledSourceOption').should('exist') +}) + +Then('the enabled options are transferred to the picked list', () => { + // if the aliased elements don't exist, it hasn't been removed + // from the source list. Transferring does not need to be tested + // here as that's covered by another test + cy.get('@enabledSourceOptions').should('not.exist') +}) + +Then('the previously highlighted items are still highlighted', () => { + cy.get('@enabledHighlightedSourceOptions').should( + 'have.class', + 'highlighted' + ) +}) + +Then('only the previously highlighted items are highlighted', () => { + cy.get('@enabledHighlightedSourceOptions').should( + 'have.class', + 'highlighted' + ) + + cy.all( + () => cy.get('{transfer-sourceoptions} {transferoption}'), + () => cy.get('@enabledHighlightedSourceOptions') + ).should(([$sourceOptions, $previouslyHighlightedOptions]) => { + const sourceOptions = $sourceOptions + .toArray() + .map(extractOptionFromElement) + + const previouslyHighlightedOptions = $previouslyHighlightedOptions + .toArray() + .map(extractOptionFromElement) + + const notHighlightedSourceOptions = sourceOptions.filter( + sourceOption => + !previouslyHighlightedOptions.find( + previouslyHighlightedOption => { + return ( + sourceOption.value === + previouslyHighlightedOption.value && + sourceOption.label === + previouslyHighlightedOption.label + ) + } + ) + ) + + expect(notHighlightedSourceOptions).to.not.have.class('highlighted') + }) +}) + +Then('the enabled options in the range are highlighted', () => { + cy.all( + () => cy.get('{transfer-sourceoptions} {transferoption}'), + () => cy.get('@clickedEnabledOption'), + () => cy.get('@clickedWithShiftEnabledOption') + ).should( + ([$all, $clickedEnabledOption, $clickedWithShiftEnabledOption]) => { + const from = $clickedEnabledOption.index() + const to = $clickedWithShiftEnabledOption.index() + const $allInRange = $all.slice(from, to + 1) + const $allInRangeEnabled = $allInRange.filter(':not(.disabled)') + + expect($allInRangeEnabled).to.have.class('highlighted') + } + ) +}) diff --git a/cypress/integration/Transfer/display-order.feature b/cypress/integration/Transfer/display-order.feature new file mode 100644 index 0000000000..e788df0bd0 --- /dev/null +++ b/cypress/integration/Transfer/display-order.feature @@ -0,0 +1,30 @@ +@component-transfer @display-ordering +Feature: Display order of items in lists + + Scenario: All supplied options are rendered in the options-side + Given none of the supplied options have been selected + Then the order of the selectable options should match the order of the supplied options + + Scenario: Some of the supplied options have been selected + Given some of the supplied options have been selected + Then the order of the remaining selectable options should match the order of the supplied options + + Scenario: A single selected options is deselected + Given some of the supplied options have been selected + When the user deselects one selected option + Then it should be positioned according to the order of the supplied options + + Scenario: Multiple selected options are deselected + Given some of the supplied options have been selected + When the user deselects multiple selected options + Then all should take the position according to the order of the supplied options + + Scenario: An selectable option gets selected + Given some selectable options are selectable + When the user selects one option + Then it should be added to the end of the selected options list + + Scenario: Multiple selectable options get selected + Given some selectable options are selectable + When the user selects multiple options + Then they should be added to the end of the selected options list in the order they have been highlighted diff --git a/cypress/integration/Transfer/display-order/index.js b/cypress/integration/Transfer/display-order/index.js new file mode 100644 index 0000000000..a077264c34 --- /dev/null +++ b/cypress/integration/Transfer/display-order/index.js @@ -0,0 +1,207 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' +import { extractOptionFromElement } from '../common' + +Given('none of the supplied options have been selected', () => { + cy.visitStory('Transfer Display Order', 'No Selection') +}) + +Given('some of the supplied options have been selected', () => { + cy.visitStory('Transfer Display Order', 'Some Selected') +}) + +Given('some selectable options are selectable', () => { + cy.visitStory('Transfer Display Order', 'Some Selected') +}) + +When('the user deselects one selected option', () => { + cy.get('{transfer-pickedoptions} {transferoption}') + .first() + .click() + cy.get('{transfer-pickedoptions}') + .find('.highlighted') + .then($el => $el.toArray().map(extractOptionFromElement)) + .as('deselectedOptions') + cy.get('{transfer-actions-removeindividual}').click() +}) + +When('the user deselects multiple selected options', () => { + cy.get('{transfer-pickedoptions} {transferoption}') + // should take third, then first item + .then($options => { + const $arr = $options.toArray() + return [$arr[2], $arr[0]] + }) + .each($option => cy.wrap($option).clickWith('cmd')) + .then($options => + cy + .wrap($options.toArray().map(extractOptionFromElement)) + .as('deselectedOptions') + ) + + cy.get('{transfer-actions-removeindividual}').click() +}) + +When('the user selects one option', () => { + cy.get('{transfer-sourceoptions} {transferoption}') + .first() + .click() + cy.get('{transfer-sourceoptions}') + .find('.highlighted') + .then($el => $el.toArray().map(extractOptionFromElement)) + .as('selectedOptions') + cy.get('{transfer-actions-addindividual}').click() +}) + +When('the user selects multiple options', () => { + cy.get('{transfer-sourceoptions} {transferoption}') + // should take fifth, then first item + .then($options => { + const $arr = $options.toArray() + return [$arr[4], $arr[0]] + }) + .each($option => cy.wrap($option).clickWith('ctrl')) + .then($options => { + cy.wrap($options.toArray().map(extractOptionFromElement)).as( + 'selectedOptions' + ) + }) + + cy.get('{transfer-actions-addindividual}').click() +}) + +Then( + 'the order of the selectable options should match the order of the supplied options', + () => { + cy.all( + () => cy.window(), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([win, $options]) => { + const { options } = win + const selectableSourceOptions = $options + .toArray() + .map(extractOptionFromElement) + + expect(selectableSourceOptions).to.eql(options) + }) + } +) + +Then( + 'the order of the remaining selectable options should match the order of the supplied options', + () => { + cy.all( + () => cy.window(), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([win, $options]) => { + const selectableSourceOptions = $options + .toArray() + .map(extractOptionFromElement) + + const options = win.options.filter(option => + selectableSourceOptions.find( + ({ label, value }) => + label === option.label && value === option.value + ) + ) + + expect(selectableSourceOptions).to.eql(options) + }) + } +) + +Then( + 'it should be positioned according to the order of the supplied options', + () => { + cy.all( + () => cy.window(), + () => cy.get('{transfer-sourceoptions} {transferoption}'), + () => cy.get('@deselectedOptions') + ).should(([win, $selectableSourceOptions, deselectedOptions]) => { + // filter out non-selectable options and compare with selectable options + // this confirms that the order is correct + const selectableSourceOptions = $selectableSourceOptions + .toArray() + .map(extractOptionFromElement) + const originalOptions = win.options.filter(option => + selectableSourceOptions.find( + ({ label, value }) => + label === option.label && value === option.value + ) + ) + expect(selectableSourceOptions).to.eql(originalOptions) + + // and confirm that the deselected option is in the selectable options list + const [deselectedOption] = deselectedOptions + const hasOption = !!selectableSourceOptions.find( + ({ label, value }) => + label === deselectedOption.label && + value === deselectedOption.value + ) + expect(hasOption).to.equal(true) + }) + } +) + +Then( + 'all should take the position according to the order of the supplied options', + () => { + cy.all( + () => cy.window(), + () => cy.get('{transfer-sourceoptions} {transferoption}'), + () => cy.get('@deselectedOptions') + ).should(([win, $selectableSourceOptions, deselectedOptions]) => { + const selectableSourceOptions = $selectableSourceOptions + .toArray() + .map(extractOptionFromElement) + const options = win.options.filter(option => + selectableSourceOptions.find( + ({ label, value }) => + label === option.label && value === option.value + ) + ) + expect(selectableSourceOptions).to.eql(options) + + const hasAllOptions = deselectedOptions.every(deselectedOption => { + const result = !!selectableSourceOptions.find( + ({ label, value }) => + label === deselectedOption.label && + value === deselectedOption.value + ) + + return result + }) + expect(hasAllOptions).to.equal(true) + }) + } +) + +Then('it should be added to the end of the selected options list', () => { + cy.all( + () => cy.get('@selectedOptions'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([transferredOptions, $selectedOptions]) => { + const lastSelectedOptions = $selectedOptions + .toArray() + .slice(transferredOptions.length * -1) + .map(extractOptionFromElement) + + expect(transferredOptions).to.eql(lastSelectedOptions) + }) +}) + +Then( + 'they should be added to the end of the selected options list in the order they have been highlighted', + () => { + cy.all( + () => cy.get('@selectedOptions'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([transferredOptions, $selectedOptions]) => { + const lastSelectedOptions = $selectedOptions + .toArray() + .slice(transferredOptions.length * -1) + .map(extractOptionFromElement) + + expect(transferredOptions).to.eql(lastSelectedOptions) + }) + } +) diff --git a/cypress/integration/Transfer/filter-options-list.feature b/cypress/integration/Transfer/filter-options-list.feature new file mode 100644 index 0000000000..8e784f6a20 --- /dev/null +++ b/cypress/integration/Transfer/filter-options-list.feature @@ -0,0 +1,40 @@ +@component-transfer @filtering +Feature: Filter options list + + Background: + Given filtering is enabled + And the options list is being filtered + + Scenario: Filtering with results + Given the result is not empty + Then all the matching items should be shown in the options list + + Scenario: Filtering without results + Given the result is empty + Then no items should be shown in the options list + + Scenario: Displaying no-result message + Given the result is empty + And a no-result message has been provided + Then information should be displayed that no items matched the filter + + Scenario Outline: Filter shows result regardless of uppercase and lowercase + Given the options are being search with a "" search term + And some options are listed + When the user uses the same search term but "" + Then the same options should be shown + + Examples: + | firstCase | secondCase | + | uppercase | lowercase | + | lowercase | uppercase | + + Scenario: The app defines a custom filtering method + Given the filter function only returns ANC options + When searching for "s" + Then only the results including the word "ANC" are included + + Scenario: The filter value can be controlled by the app + Given the filter value is controlled by the component's consumer + When the filter value changes + Then the onFilterChange callback will be called with the new value diff --git a/cypress/integration/Transfer/filter-options-list/index.js b/cypress/integration/Transfer/filter-options-list/index.js new file mode 100644 index 0000000000..d45f53c767 --- /dev/null +++ b/cypress/integration/Transfer/filter-options-list/index.js @@ -0,0 +1,133 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +import { extractOptionFromElement } from '../common' + +Given('filtering is enabled', () => { + // no op +}) + +Given('the options list is being filtered', () => { + // no op +}) + +Given('the result is not empty', () => { + cy.visitStory('Transfer filtering', 'Some Results') +}) + +Given('the result is empty', () => { + cy.visitStory('Transfer filtering', 'Empty Result') +}) + +Given('a no-result message has been provided', () => { + // no op +}) + +Given('the options are being search with a {string} search term', firstCase => { + if (firstCase === 'uppercase') { + cy.visitStory('Transfer filtering', 'Uppercase Search') + } else if (firstCase === 'lowercase') { + cy.visitStory('Transfer filtering', 'Lowercase Search') + } + + cy.get('{transfer-filter} input') + .then($input => $input.val()) + .as('firstCaseTerm') +}) + +Given('some options are listed', () => { + cy.get('{transfer-sourceoptions} {transferoption}') + .should('have.length.of.at.least', 1) + .as('firstCaseOptions') +}) + +Given('the filter function only returns ANC options', () => { + cy.visitStory('Transfer filtering', 'Anc Custom Filter') +}) + +Given("the filter value is controlled by the component's consumer", () => { + cy.visitStory('Transfer filtering', 'Controlled Filter') +}) + +When('the user uses the same search term but {string}', secondCase => { + cy.all( + () => cy.get('@firstCaseTerm'), + () => cy.get('{transfer-filter} input') + ).then(([firstCaseTerm, $filterInput]) => { + let secondCaseTerm + + if (secondCase === 'uppercase') { + secondCaseTerm = firstCaseTerm.toUpperCase() + } else if (secondCase === 'lowercase') { + secondCaseTerm = firstCaseTerm.toLowerCase() + } + + cy.wrap($filterInput) + .clear() + .type(secondCaseTerm) + }) +}) + +When('searching for "s"', () => { + cy.get('{transfer-filter} input').type('s') +}) + +When('the filter value changes', () => { + cy.get('{transfer-filter} input') + .then($input => console.log('$input', $input) || $input) + .type('ANC') +}) + +Then('all the matching items should be shown in the options list', () => { + cy.all( + () => cy.get('{transfer-filter}'), + () => cy.get('{transferoption}') + ).should(([$filter, $options]) => { + const searchTerm = $filter.val() + + expect($options).to.have.length.of.at.least(1) + $options.each((index, option) => { + const text = Cypress.$(option).text() + expect(text).to.match(new RegExp(searchTerm)) + }) + }) +}) + +Then('no items should be shown in the options list', () => { + cy.get('{transferoption}').should('not.exist') +}) + +Then('information should be displayed that no items matched the filter', () => { + cy.get('{no-results}', { prefix: '' }).should('exist') +}) + +Then('the same options should be shown', () => { + cy.all( + () => cy.get('@firstCaseOptions'), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([$firstCaseOptions, $secondCaseOptions]) => { + const firstCaseOptions = $firstCaseOptions + .toArray() + .map(extractOptionFromElement) + const secondCaseOptions = $secondCaseOptions + .toArray() + .map(extractOptionFromElement) + + expect(firstCaseOptions).to.eql(secondCaseOptions) + }) +}) + +Then('only the results including the word "ANC" are included', () => { + cy.get('{transfer-sourceoptions} {transferoption}').each($option => + expect($option.text()).to.match(/ANC/) + ) +}) + +Then('the onFilterChange callback will be called with the new value', () => { + cy.window().should(win => { + expect(win.customFilterCallback.callCount).to.equal(4) + expect(win.customFilterCallback.getCall(0).args[1]).to.equal('') + expect(win.customFilterCallback.getCall(1).args[1]).to.equal('A') + expect(win.customFilterCallback.getCall(2).args[1]).to.equal('AN') + expect(win.customFilterCallback.getCall(3).args[1]).to.equal('ANC') + }) +}) diff --git a/cypress/integration/Transfer/highlight-range-of-options.feature b/cypress/integration/Transfer/highlight-range-of-options.feature new file mode 100644 index 0000000000..da911217ca --- /dev/null +++ b/cypress/integration/Transfer/highlight-range-of-options.feature @@ -0,0 +1,70 @@ +Feature: Highlight a range of options + + Scenario: The user highlights an option with the SHIFT modifier key + Given the option list has one or more items + And no item is highlighted + When the user clicks an item with the SHIFT modifier key + Then the clicked option should be highlighted + + Scenario: The user un-highlights an option with the SHIFT modifier key + Given the option list has one or more items + And one item is highlighted + When the user clicks the highlighted item with the SHIFT modifier key + Then the option is not highlighted + + Scenario Outline: The user highlights a range of options with the SHIFT modifier key + Given the list has three or more items + And one item is highlighted + And there are at least two options following the highlighted option + When the user highlightes the second options following the highlighted option with the SHIFT key modifier + Then the clicked options should be highlighted + Then all options between the initially highlighted option and the clicked option are highlighted + + Examples: + | type | + | option | + | selected | + + Scenario Outline: The user highlights a range of options with the SHIFT modifier key while multiple are highlighted + Given the list has three or more items + And several options are highlighted + And there are at least two options following the last highlighted option + When the user highlightes the second options following the last highlighted option + Then the option highlighted most recently without SHIFT should be highlighted + And the option clicked with SHIFT should be highlighted + And all options between the option highlighted most recently without SHIFT and the clicked option are highlighted + And all other previously highlighted options are not highlighted anymore + + Examples: + | type | + | option | + | selected | + + Scenario Outline: The user highlights hides the last-without-SHIFT clicked option and selects a range + Given the option list has many items + And several options are highlighted + When the user "" clicks a highlighted option + And the user changed the filter to exclude the last clicked option + And the user SHIFT-clicks a non-highlighted option + Then the range from the visually first highlighted option to the SHIFT-clicked option is highlighted + + Examples: + | mod | + | ctrl | + | cmd | + | alt | + + Scenario Outline: The user highlights highlights the first option with SHIFT, then highlights a range with SHIFT + Given the list has many items + When the user SHIFT-clicks an option + And the user SHIFT-clicks another option + Then the range from the first clicked option to the second clicked option is highlighted + + Examples: + | type | mod | + | option | ctrl | + | option | cmd | + | option | alt | + | selected | ctrl | + | selected | cmd | + | selected | alt | diff --git a/cypress/integration/Transfer/highlight-range-of-options/index.js b/cypress/integration/Transfer/highlight-range-of-options/index.js new file mode 100644 index 0000000000..7306393141 --- /dev/null +++ b/cypress/integration/Transfer/highlight-range-of-options/index.js @@ -0,0 +1,352 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +import { extractOptionFromElement } from '../common' + +Given('the option list has one or more items', () => { + cy.visitStory('Transfer highlight range of options', 'Has Options') + cy.get('{transfer-sourceoptions}') + .as('list') + .find('{transferoption}') + .should('have.length.of.at.least', 1) +}) + +Given('the selected list has one or more items', () => { + cy.visitStory('Transfer highlight range of options', 'Has Selected') + cy.get('{transfer-pickedoptions}') + .as('list') + .as('list') + .find('{transferoption}') + .should('have.length.of.at.least', 1) +}) + +Given('the option list has three or more items', () => { + cy.visitStory('Transfer highlight range of options', 'Has Options') + cy.get('{transfer-sourceoptions}') + .as('list') + .as('list') + .find('{transferoption}') + .should('have.length.of.at.least', 3) +}) + +Given('the selected list has three or more items', () => { + cy.visitStory('Transfer highlight range of options', 'Has Selected') + cy.get('{transfer-pickedoptions}') + .as('list') + .as('list') + .find('{transferoption}') + .should('have.length.of.at.least', 3) +}) + +Given('the option list has many items', () => { + cy.visitStory('Transfer highlight range of options', 'Has Options') + cy.get('{transfer-sourceoptions}') + .as('list') + .as('list') + .find('{transferoption}') + .should('have.length.of.at.least', 5) +}) + +Given('the selected list has many items', () => { + cy.visitStory('Transfer highlight range of options', 'All Selected') + cy.get('{transfer-pickedoptions}') + .as('list') + .as('list') + .find('{transferoption}') + .should('have.length.of.at.least', 5) +}) + +Given('no item is highlighted', () => { + cy.get('@list') + .find('.highlighted') + .should('not.exist') +}) + +Given('one item is highlighted', () => { + cy.get('@list') + .find('{transferoption}') + .first() + .as('initiallyHighlighted') + .click() + .should('have.class', 'highlighted') +}) + +Given('there are at least two options following the highlighted option', () => { + cy.get('@initiallyHighlighted') + .next() + .should('exist') + .as('firstBelowInitiallyHighlighted') + .next() + .should('exist') + .as('secondBelowInitiallyHighlighted') +}) + +Given('several options are highlighted', () => { + cy.get('@list') + .find('{transferoption}') + .then($option => + $option + .eq(0) + .add($option.eq(4)) + .add($option.eq(6)) + ) + .as('initiallyHighlightedMultiple') + .each($option => cy.wrap($option).clickWith('ctrl')) +}) + +Given( + 'there are at least two options following the last highlighted option', + () => { + cy.get('@initiallyHighlightedMultiple') + .last('last') + .next() + .should('exist') + .as('firstBelowInitiallyHighlighted') + .next() + .should('exist') + .as('secondBelowInitiallyHighlighted') + } +) + +When('the user clicks an item with the SHIFT modifier key', () => { + cy.get('@list') + .find('{transferoption}') + .first() + .clickWith('ctrl') + .as('initiallyHighlighted') +}) + +When('the user clicks the highlighted item with the SHIFT modifier key', () => { + cy.get('@initiallyHighlighted') + .clickWith('shift') + .as('hiddenHighlighted') +}) + +When( + 'the user highlightes the second options following the highlighted option with the SHIFT key modifier', + () => { + cy.get('@secondBelowInitiallyHighlighted').clickWith('shift') + } +) + +When( + 'the user highlightes the second options following the last highlighted option', + () => { + cy.get('@initiallyHighlightedMultiple') + // last highlighted option + .last() + + // next sibling + .next() + .as('firstBelowLastHighlighted') + + // second next sibling + .next() + .as('secondBelowLastHighlighted') + .clickWith('shift') + } +) + +When('the user {string} clicks a highlighted option', modifierKey => { + cy.get('@initiallyHighlightedMultiple') + .eq(0) + .clickWith(modifierKey) + .as('controlClicked') +}) + +When('the user changed the filter to exclude the last clicked option', () => { + cy.get('{transfer-filter} input').type('ARI') +}) + +When('the user SHIFT-clicks a non-highlighted option', () => { + cy.get('@list') + .find('{transferoption}') + .invoke('eq', 4) + .clickWith('shift') + .as('firstShiftClicked') +}) + +When('the user SHIFT-clicks an option', () => { + cy.get('@list') + .find('{transferoption}') + .eq(0) + .clickWith('shift') + + cy.wrap(0).as('firstClickedIndexWithShift') +}) + +When('the user SHIFT-clicks another option', () => { + cy.get('@list') + .find('{transferoption}') + .eq(5) + .clickWith('shift') + + cy.wrap(5).as('secondClickedIndexWithShift') +}) + +Then('the clicked option should be highlighted', () => { + cy.get('@initiallyHighlighted').should('have.class', 'highlighted') +}) + +Then('the option is not highlighted', () => { + cy.all( + () => cy.get('@hiddenHighlighted'), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([hiddenHighlighted, $options]) => { + const $hiddenHighlighted = $options.filter((index, optionEl) => { + const option = extractOptionFromElement(optionEl) + + return ( + option.label === hiddenHighlighted.label && + option.value === hiddenHighlighted.value + ) + }) + + expect($hiddenHighlighted).not.to.have.class('highlighted') + }) +}) + +Then('the clicked options should be highlighted', () => { + cy.all( + () => cy.get('@initiallyHighlighted'), + () => cy.get('@secondBelowInitiallyHighlighted') + ).should(([$initiallyHighlighted, $secondBelowInitiallyHighlighted]) => { + expect($initiallyHighlighted).to.have.class('highlighted') + expect($secondBelowInitiallyHighlighted).to.have.class('highlighted') + }) +}) + +Then( + 'all options between the initially highlighted option and the clicked option are highlighted', + () => { + cy.get('@firstBelowInitiallyHighlighted').should( + 'have.class', + 'highlighted' + ) + } +) + +Then( + 'the option highlighted most recently without SHIFT should be highlighted', + () => { + cy.get('@initiallyHighlightedMultiple') + .last() + .should('have.class', 'highlighted') + } +) + +Then('the option clicked with SHIFT should be highlighted', () => { + cy.get('@secondBelowInitiallyHighlighted').should( + 'have.class', + 'highlighted' + ) +}) + +Then( + 'all options between the option highlighted most recently without SHIFT and the clicked option are highlighted', + () => { + cy.get('@firstBelowInitiallyHighlighted').should( + 'have.class', + 'highlighted' + ) + } +) + +Then( + 'all other previously highlighted options are not highlighted anymore', + () => { + cy.all( + () => + cy + .get('@initiallyHighlightedMultiple') + .last() + .invoke('index'), + () => cy.get('@initiallyHighlightedMultiple') + ).should( + ([ + lastInitiallyHighlightedIndex, + $initiallyHighlightedMultiple, + ]) => { + $initiallyHighlightedMultiple + .filter((_, el) => { + const $el = Cypress.$(el) + return $el.index() !== lastInitiallyHighlightedIndex + }) + .each((_, el) => { + const $el = Cypress.$(el) + expect($el).to.not.have.class('highlighted') + }) + } + ) + } +) + +Then( + 'the range from the visually first highlighted option to the SHIFT-clicked option is highlighted', + () => { + cy.all( + () => cy.get('@initiallyHighlightedMultiple'), + () => cy.get('@firstShiftClicked'), + () => cy.get('@list').find('{transferoption}') + ).should( + ([$initiallyHighlightedMultiple, $firstShiftClicked, $all]) => { + const firstVisibleHighlightedIndex = $initiallyHighlightedMultiple + .filter(':visible') + .eq(0) + .index() + const shiftIndex = $firstShiftClicked.index() + const from = Math.min(firstVisibleHighlightedIndex, shiftIndex) + const to = Math.max(firstVisibleHighlightedIndex, shiftIndex) + const $insideRange = $all.slice(from, to + 1) + const $outsideRange = $all + .slice(0, from) + .add($all.slice(to + 1)) + + $insideRange.each((index, option) => { + expect(Cypress.$(option)).to.have.class('highlighted') + }) + + $outsideRange.each(option => + expect(Cypress.$(option)).to.not.have.class('highlighted') + ) + } + ) + } +) + +Then( + 'the range from the first clicked option to the second clicked option is highlighted', + () => { + cy.all( + () => cy.get('@firstClickedIndexWithShift'), + () => cy.get('@secondClickedIndexWithShift'), + () => cy.get('@list').find('{transferoption}') + ).should( + ([ + firstClickedIndexWithShift, + secondClickedIndexWithShift, + $all, + ]) => { + const from = Math.min( + firstClickedIndexWithShift, + secondClickedIndexWithShift + ) + const to = Math.max( + firstClickedIndexWithShift, + secondClickedIndexWithShift + ) + const $insideRange = $all.slice(from, to + 1) + const $outsideRange = $all + .slice(0, from) + .add($all.slice(to + 1)) + + $insideRange.each((index, option) => { + expect(Cypress.$(option)).to.have.class('highlighted') + }) + + $outsideRange.each(option => + expect(Cypress.$(option)).to.not.have.class('highlighted') + ) + } + ) + } +) diff --git a/cypress/integration/Transfer/reorder-with-buttons.feature b/cypress/integration/Transfer/reorder-with-buttons.feature new file mode 100644 index 0000000000..f087aa1d30 --- /dev/null +++ b/cypress/integration/Transfer/reorder-with-buttons.feature @@ -0,0 +1,46 @@ +@component-transfer @reordering +Feature: Reorder items in the selected list using buttons + + Background: + Given reordering of items is enabled + + # I created two scenarios for up and down reordering because I wanted to + # incorporate a check for 'is first' or 'is last' and it seemed overly + # ambiguous to write this in a single scenario. Or, is it enough that I + # have included the 'Disable move buttons' scenarios at the bottom of this + # file, so that doesn't need to be handled in the basic scenarios at the + # top? + + Scenario Outline: The user clicks the 'move up' button with a highlighted item in the selected list + Given the selected list has three items + And the . item is highlighted + When the user clicks the 'move up' button + Then the highlighted item should be moved to the . place + + Examples: + | previous | next | + | 1 | 1 | + | 2 | 1 | + | 3 | 2 | + + Scenario Outline: The user clicks the 'move down' button with a highlighted item in the selected list + Given the selected list has three items + And the . item is highlighted + When the user clicks the 'move down' button + Then the highlighted item should be moved to the . place + + Examples: + | previous | next | + | 1 | 2 | + | 2 | 3 | + | 3 | 3 | + + Scenario: Disable reorder buttons when no items are highlighted + Given the selected list has some items + And no items are highlighted in the list + Then the 'move up' and 'move down' buttons should be disabled + + Scenario: Disabled reorder buttons when multiple selected items are highlighted + Given the selected list has some items + And more than one item is highlighted in the list + Then the 'move up' and 'move down' buttons should be disabled diff --git a/cypress/integration/Transfer/reorder-with-buttons/index.js b/cypress/integration/Transfer/reorder-with-buttons/index.js new file mode 100644 index 0000000000..849b0eb779 --- /dev/null +++ b/cypress/integration/Transfer/reorder-with-buttons/index.js @@ -0,0 +1,60 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('reordering of items is enabled', () => { + // no op +}) + +Given('the selected list has three items', () => { + cy.visitStory('Transfer Reorder Buttons', 'Has Some Selected') +}) + +Given('the selected list has some items', () => { + cy.visitStory('Transfer Reorder Buttons', 'Has Some Selected') +}) + +Given('the {int}. item is highlighted', previousPisition => { + const index = previousPisition - 1 + + cy.get('{transfer-pickedoptions} {transferoption}') + .eq(index) + .as('previous') + .click() +}) + +Given('no items are highlighted in the list', () => { + cy.get('{transfer-pickedoptions} {transferoption}').should('not.exist') +}) + +Given('more than one item is highlighted in the list', () => { + cy.get('{transfer-pickedoptions} {transferoption}') + .filter(index => index < 2) + .each($option => cy.wrap($option).clickWith('ctrl')) +}) + +When("the user clicks the 'move up' button", () => { + // force, so we click disabled buttons + cy.get('{transfer-reorderingactions-buttonmoveup}').click({ force: true }) +}) + +When("the user clicks the 'move down' button", () => { + // force, so we click disabled buttons + cy.get('{transfer-reorderingactions-buttonmovedown}').click({ force: true }) +}) + +Then('the highlighted item should be moved to the {int}. place', next => { + const index = next - 1 + cy.get('@previous') + .invoke('index') + .should('equal', index) +}) + +Then("the 'move up' and 'move down' buttons should be disabled", () => { + cy.get('{transfer-reorderingactions-buttonmoveup}').should( + 'have.attr', + 'disabled' + ) + cy.get('{transfer-reorderingactions-buttonmovedown}').should( + 'have.attr', + 'disabled' + ) +}) diff --git a/cypress/integration/Transfer/set_unset-highlighted-option.feature b/cypress/integration/Transfer/set_unset-highlighted-option.feature new file mode 100644 index 0000000000..825a0bdfa5 --- /dev/null +++ b/cypress/integration/Transfer/set_unset-highlighted-option.feature @@ -0,0 +1,42 @@ +@component-transfer @highlighting +Feature: Set&unset the highlighted option + + Scenario Outline: The user clicks an item that is not already highlighted + Given the list has one or more items + And one item is highlighted + When the user clicks an item in the list that is not highlighted + Then the clicked item should be highlighted + And the previously highlighted item should no longer be highlighted + + Examples: + | type | + | option | + | selected | + + Scenario Outline: The user clicks a highlighted item + Given the list has one or more items + And one item is highlighted + When the user clicks an item in the list that is highlighted + Then the previously highlighted item should no longer be highlighted + + Examples: + | type | + | option | + | selected | + + Scenario: The user highlights an option, changes the filter to exclude that option & then changes the filter back + Given the option list has one or more items + And one item is highlighted + But the highlighted item is not visible due to a set filter + When the users changes the filter to include the hidden option + Then the option is visible + And the option is highlighted + + Scenario: The user highlights an option, changes the filter to exclude that option, selects the highlighted options & then changes the filter back + Given the option list has one or more items + And one item is highlighted + But the highlighted item is not visible due to a set filter + When the user selects the visible, highlighted options + And the users changes the filter to include the hidden option + Then the option is visible + And the option is not highlighted diff --git a/cypress/integration/Transfer/set_unset-highlighted-option/index.js b/cypress/integration/Transfer/set_unset-highlighted-option/index.js new file mode 100644 index 0000000000..d714bc97af --- /dev/null +++ b/cypress/integration/Transfer/set_unset-highlighted-option/index.js @@ -0,0 +1,126 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +import { extractOptionFromElement } from '../common' + +Given('the option list has one or more items', () => { + cy.visitStory('Transfer set & unset higlighted options', 'Has Options') + cy.get('{transfer-sourceoptions}').as('list') +}) + +Given('the selected list has one or more items', () => { + cy.visitStory('Transfer set & unset higlighted options', 'Has Selected') + cy.get('{transfer-pickedoptions}').as('list') +}) + +Given('no item is highlighted', () => { + cy.get('@list') + .find('.highlighted') + .should('not.exist') +}) + +Given('one item is highlighted', () => { + cy.get('@list') + .find('{transferoption}') + .first() + .as('initiallyHighlighted') + .click() + .should('have.class', 'highlighted') +}) + +Given('the highlighted item is not visible due to a set filter', () => { + // store hidden option because dom reference will be lost + cy.get('@initiallyHighlighted') + .then($initiallyHighlighted => + extractOptionFromElement($initiallyHighlighted) + ) + .as('hiddenHighlighted') + + cy.get('{transfer-filter} input').type('No result search term') + + cy.get('{transfer-sourceoptions} {transferoption}').should('not.exist') +}) + +When('the user clicks an item in the list that is not highlighted', () => { + cy.get('@list') + .find('{transferoption}') + .first() + .invoke('next') + .as('nextHighlighted') + .click() +}) + +When('the user clicks an item in the list that is highlighted', () => { + cy.get('@initiallyHighlighted') + .wait(500) + .click() +}) + +When('the user selects the visible, highlighted options', () => { + cy.get('{transfer-actions-addindividual}').click() +}) + +When('the users changes the filter to include the hidden option', () => { + cy.get('{transfer-filter} input').clear() +}) + +Then('the clicked item should be highlighted', () => { + cy.get('@nextHighlighted').should('have.class', 'highlighted') +}) + +Then('the previously highlighted item should no longer be highlighted', () => { + cy.get('@initiallyHighlighted').should('have.not.class', 'highlighted') +}) + +Then('the option is visible', () => { + cy.all( + () => cy.get('@hiddenHighlighted'), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([hiddenHighlighted, $options]) => { + const $hiddenHighlighted = $options.filter((index, optionEl) => { + const option = extractOptionFromElement(optionEl) + + return ( + option.label === hiddenHighlighted.label && + option.value === hiddenHighlighted.value + ) + }) + + expect($hiddenHighlighted).to.be.visible + }) +}) + +Then('the option is highlighted', () => { + cy.all( + () => cy.get('@hiddenHighlighted'), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([hiddenHighlighted, $options]) => { + const $hiddenHighlighted = $options.filter((index, optionEl) => { + const option = extractOptionFromElement(optionEl) + + return ( + option.label === hiddenHighlighted.label && + option.value === hiddenHighlighted.value + ) + }) + + expect($hiddenHighlighted).to.have.class('highlighted') + }) +}) + +Then('the option is not highlighted', () => { + cy.all( + () => cy.get('@hiddenHighlighted'), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([hiddenHighlighted, $options]) => { + const $hiddenHighlighted = $options.filter((index, optionEl) => { + const option = extractOptionFromElement(optionEl) + + return ( + option.label === hiddenHighlighted.label && + option.value === hiddenHighlighted.value + ) + }) + + expect($hiddenHighlighted).not.to.have.class('highlighted') + }) +}) diff --git a/cypress/integration/Transfer/transferring-items.feature b/cypress/integration/Transfer/transferring-items.feature new file mode 100644 index 0000000000..3a81c231ca --- /dev/null +++ b/cypress/integration/Transfer/transferring-items.feature @@ -0,0 +1,44 @@ +@component-transfer @transferring +Feature: Transferring items between lists + + Scenario: The user selects multiple items + Given some options are selectable + And some items in the options list are highlighted + When the user clicks the 'move to selected list' button + Then the highlighted items should be removed from the options list + And the highlighted items should be visible in the selected list + And the highlighted items should be appended to the selected list in the order they were highlighted + + Scenario: The user deselects multiple items + Given some options are selected + And some items in the selected list are highlighted + When the user clicks the 'move to options list' button + Then the highlighted items should be removed from the selected list + And the highlighted items should be visible in the options list + And the highlighted items should be in the original options list ordering + + Scenario: The user selects all items + Given some options are selected + When the user clicks the 'move all to selected list' button + Then all items should be removed from the options list + And all items removed from options list should be visible in the selected list + And the transferred items should be appended to the selected list in the order they were displayed in the options list + + Scenario: The user deselects all items + Given some options are selected + When the user clicks the 'move all to options list' button + Then all items should be removed from the selected list + And all items removed from selected list should be visible in the options list + And the options list items should be ordered in the original order + + Scenario: The user double clicks an item in the options list + Given some options are selectable + When the user double clicks an item in the options list + Then the item should be removed from its options list + And the item should be visible at the bottom of the selected list + + Scenario: The user double clicks an item in the selected list + Given some options are selected + When the user double clicks an item in the selected list + Then the item should be removed from the selected list + And the options list items should be ordered in the original order diff --git a/cypress/integration/Transfer/transferring-items/index.js b/cypress/integration/Transfer/transferring-items/index.js new file mode 100644 index 0000000000..55923829a4 --- /dev/null +++ b/cypress/integration/Transfer/transferring-items/index.js @@ -0,0 +1,393 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +import { parseSelectorWithDataTest } from '../../../support/common/parseSelectorWithDataTest' +import { extractOptionFromElement } from '../common' + +Given('some options are selectable', () => { + cy.visitStory('Transfer Transferring Items', 'Has Options') +}) + +Given('some options are selected', () => { + cy.visitStory('Transfer Transferring Items', 'Some Selected') +}) + +Given('one or more items in the options list are highlighted', () => { + cy.get('{transfer-sourceoptions} {transferoption}') + .filter(index => index < 3) + // shuffle order so we can check they're added in the right order + .then($options => cy.wrap([$options[2], $options[0], $options[1]])) + .each($option => cy.wrap($option).clickWith('ctrl')) + .then($options => $options.toArray().map(extractOptionFromElement)) + .as('itemsToBeSelected') +}) + +Given('some items in the options list are highlighted', () => { + cy.get('{transfer-sourceoptions} {transferoption}') + .filter(index => index < 3) + // shuffle order so we can check they're added in the right order + .then($options => cy.wrap([$options[2], $options[0], $options[1]])) + .each($option => cy.wrap($option).clickWith('ctrl')) + .then($options => $options.toArray().map(extractOptionFromElement)) + .as('itemsToBeSelected') +}) + +Given('some items in the selected list are highlighted', () => { + cy.get('{transfer-pickedoptions} {transferoption}') + .filter(index => index < 3) + // shuffle order so we can check they're added in the right order + .then($options => cy.wrap([$options[2], $options[0], $options[1]])) + .each($option => cy.wrap($option).clickWith('ctrl')) + .then($options => $options.toArray().map(extractOptionFromElement)) + .as('itemsToBeDeselected') +}) + +When("the user clicks the 'move to selected list' button", () => { + cy.get('{transfer-actions-addindividual}').click() +}) + +When("the user clicks the 'move to options list' button", () => { + cy.get('{transfer-actions-removeindividual}').click() +}) + +When("the user clicks the 'move all to selected list' button", () => { + cy.get('{transfer-sourceoptions} {transferoption}') + .then($options => $options.toArray().map(extractOptionFromElement)) + .as('itemsToBeSelected') + cy.get('{transfer-actions-addall}').click() +}) + +When("the user clicks the 'move all to options list' button", () => { + cy.get('{transfer-pickedoptions} {transferoption}') + .then($options => $options.toArray().map(extractOptionFromElement)) + .as('itemsToBeDeselected') + cy.get('{transfer-actions-removeall}').click() +}) + +When('the user double clicks an item in the options list', () => { + cy.get('{transfer-sourceoptions} {transferoption}') + .first() + .dblclick() + .then(extractOptionFromElement) + .as('doubleClickedPlainOption') +}) + +When('the user double clicks an item in the selected list', () => { + cy.get('{transfer-pickedoptions} {transferoption}') + .first() + .dblclick() + .then(extractOptionFromElement) + .as('doubleClickedPlainOption') +}) + +Then('the highlighted items should be removed from the options list', () => { + cy.all( + () => cy.get('@itemsToBeSelected'), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([itemsToBeSelected, $selectableSourceOptions]) => { + const selectableSourceOptions = $selectableSourceOptions + .toArray() + .map(extractOptionFromElement) + const itemsStillSelectable = itemsToBeSelected.every( + itemToBeSelected => { + const result = selectableSourceOptions.find( + ({ label, value }) => + label === itemToBeSelected.label && + value === itemToBeSelected.value + ) + + return result + } + ) + + expect(itemsStillSelectable).to.equal(false) + }) +}) + +Then('the highlighted items should be visible in the selected list', () => { + cy.all( + () => cy.get('@itemsToBeSelected'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([itemsToBeSelected, $selectedOptions]) => { + const selectedOptions = $selectedOptions + .toArray() + .map(extractOptionFromElement) + const itemsSelected = itemsToBeSelected.every(itemToBeSelected => + selectedOptions.find( + ({ label, value }) => + label === itemToBeSelected.label && + value === itemToBeSelected.value + ) + ) + + expect(itemsSelected).to.equal(true) + }) +}) + +Then( + 'the highlighted items should be appended to the selected list in the order they were highlighted', + () => { + cy.all( + () => cy.get('@itemsToBeSelected'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([itemsToBeSelected, $selectedOptions]) => { + const lastNSelectedOptions = $selectedOptions + .toArray() + .map(extractOptionFromElement) + .slice(itemsToBeSelected.length * -1) + + expect(itemsToBeSelected).to.eql(lastNSelectedOptions) + }) + } +) + +Then('the highlighted items should be removed from the selected list', () => { + cy.all( + () => cy.get('@itemsToBeDeselected'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([itemsToBeDeselected, $selectedOptions]) => { + const selectedOptions = $selectedOptions + .toArray() + .map(extractOptionFromElement) + const itemsStillSelected = itemsToBeDeselected.every( + itemToBeSelected => { + const result = selectedOptions.find( + ({ label, value }) => + label === itemToBeSelected.label && + value === itemToBeSelected.value + ) + + return result + } + ) + + expect(itemsStillSelected).to.equal(false) + }) +}) + +Then('the highlighted items should be visible in the options list', () => { + cy.all( + () => cy.get('@itemsToBeDeselected'), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([itemsToBeDeselected, $selectedOptions]) => { + const selectedOptions = $selectedOptions + .toArray() + .map(extractOptionFromElement) + const itemsSelectable = itemsToBeDeselected.every(itemToBeSelected => { + const result = selectedOptions.find( + ({ label, value }) => + label === itemToBeSelected.label && + value === itemToBeSelected.value + ) + + return result + }) + + expect(itemsSelectable).to.equal(true) + }) +}) + +Then( + 'the highlighted items should be in the original options list ordering', + () => { + cy.all( + () => cy.window(), + () => cy.get('{transfer-sourceoptions} {transferoption}'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([win, $selectableSourceOptions, $selectedOptions]) => { + const selectedOptions = $selectedOptions + .toArray() + .map(extractOptionFromElement) + const selectableSourceOptions = $selectableSourceOptions + .toArray() + .map(extractOptionFromElement) + const allOptionsWithoutSelected = win.options.filter(option => { + return !selectedOptions.find( + ({ label, value }) => + option.label === label && option.value === value + ) + }) + + expect(allOptionsWithoutSelected).to.eql(selectableSourceOptions) + }) + } +) + +Then('all items should be removed from the options list', () => { + cy.get('{transfer-sourceoptions} {transferoption}').should('not.exist') +}) + +Then( + 'all items removed from options list should be visible in the selected list', + () => { + cy.all( + () => cy.get('@itemsToBeSelected'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([itemsToBeSelected, $selectedOptions]) => { + const selectedOptions = $selectedOptions + .toArray() + .map(extractOptionFromElement) + const allSelected = itemsToBeSelected.every(option => { + return selectedOptions.find( + ({ label, value }) => + option.label === label && option.value === value + ) + }) + + expect(allSelected).to.equal(true) + }) + } +) + +Then( + 'the transferred items should be appended to the selected list in the order they were displayed in the options list', + () => { + cy.all( + () => cy.get('@itemsToBeSelected'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([itemsToBeSelected, $selectedOptions]) => { + const selectedOptions = $selectedOptions + .toArray() + .map(extractOptionFromElement) + const previouslySelectedSubset = selectedOptions.slice( + -1 * itemsToBeSelected.length + ) + + expect(itemsToBeSelected).to.eql(previouslySelectedSubset) + }) + } +) + +Then('all items should be removed from the selected list', () => { + cy.get('{transfer-pickedoptions} {transferoption}').should('not.exist') +}) + +Then( + 'all items removed from selected list should be visible in the options list', + () => { + cy.all( + () => cy.get('@itemsToBeDeselected'), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([itemsToBeDeselected, $selectableSourceOptions]) => { + const selectableSourceOptions = $selectableSourceOptions + .toArray() + .map(extractOptionFromElement) + const allSelectable = itemsToBeDeselected.every(option => { + return selectableSourceOptions.find( + ({ label, value }) => + option.label === label && option.value === value + ) + }) + + expect(allSelectable).to.equal(true) + }) + } +) + +Then( + 'the transferred items should be appended to the selected list in the order they were displayed in the options list', + () => { + cy.all( + () => cy.get('@itemsToBeSelected'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([itemsToBeSelected, $selectedOptions]) => { + const selectedOptions = $selectedOptions + .toArray() + .map(extractOptionFromElement) + const previouslySelectedSubset = selectedOptions.slice( + -1 * itemsToBeSelected.length + ) + + expect(itemsToBeSelected).to.eql(previouslySelectedSubset) + }) + } +) + +Then('the options list items should be ordered in the original order', () => { + cy.all( + () => cy.window(), + () => cy.get('{transfer-sourceoptions} {transferoption}'), + () => + cy.get('{transfer-pickedoptions}').then($pickedOptions => { + return $pickedOptions.find( + parseSelectorWithDataTest('{transferoption}') + ) + }) + ).should(([win, $selectableSourceOptions, $pickedOptions]) => { + const pickedPlainOptions = $pickedOptions + .toArray() + .map(extractOptionFromElement) + + const originalOrderWithoutSelected = win.options.filter( + originalOption => { + return !pickedPlainOptions.find( + pickedPlainOption => + pickedPlainOption.value === originalOption.value && + pickedPlainOption.label === originalOption.label + ) + } + ) + + const selectableSourceOptions = $selectableSourceOptions + .toArray() + .map(extractOptionFromElement) + + expect(originalOrderWithoutSelected).to.eql(selectableSourceOptions) + }) +}) + +Then('the item should be removed from its options list', () => { + cy.all( + () => cy.get('@doubleClickedPlainOption'), + () => cy.get('{transfer-sourceoptions} {transferoption}') + ).should(([doubleClickedPlainOption, $sourceOptions]) => { + const sourcePlainOptions = $sourceOptions + .toArray() + .map(extractOptionFromElement) + + const found = sourcePlainOptions.find( + sourcePlainOption => + sourcePlainOption.value === doubleClickedPlainOption.value && + sourcePlainOption.label === doubleClickedPlainOption.label + ) + + expect(found).to.not.equal(true) + }) +}) + +Then('the item should be visible at the bottom of the selected list', () => { + cy.all( + () => cy.get('@doubleClickedPlainOption'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([doubleClickedPlainOption, $pickedOptions]) => { + const lastSourcePlainOption = $pickedOptions + .last() + .toArray() + .map(extractOptionFromElement) + .pop() + + const doubleClickedOptionIsLast = + lastSourcePlainOption.value === doubleClickedPlainOption.value && + lastSourcePlainOption.label === doubleClickedPlainOption.label + + expect(doubleClickedOptionIsLast).to.equal(true) + }) +}) + +Then('the item should be removed from the selected list', () => { + cy.all( + () => cy.get('@doubleClickedPlainOption'), + () => cy.get('{transfer-pickedoptions} {transferoption}') + ).should(([doubleClickedPlainOption, $pickedOptions]) => { + const pickedPlainOptions = $pickedOptions + .toArray() + .map(extractOptionFromElement) + + const found = pickedPlainOptions.find( + sourcePlainOption => + sourcePlainOption.value === doubleClickedPlainOption.value && + sourcePlainOption.label === doubleClickedPlainOption.label + ) + + expect(found).to.not.equal(true) + }) +}) diff --git a/cypress/integration/forms/CheckboxFieldFF/can_toggle_a_boolean.feature b/cypress/integration/forms/CheckboxFieldFF/can_toggle_a_boolean.feature new file mode 100644 index 0000000000..9e739596d5 --- /dev/null +++ b/cypress/integration/forms/CheckboxFieldFF/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/CheckboxFieldFF/can_toggle_a_boolean/index.js b/cypress/integration/forms/CheckboxFieldFF/can_toggle_a_boolean/index.js new file mode 100644 index 0000000000..d67d1402c4 --- /dev/null +++ b/cypress/integration/forms/CheckboxFieldFF/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/CheckboxFieldFF/can_toggle_a_value.feature b/cypress/integration/forms/CheckboxFieldFF/can_toggle_a_value.feature new file mode 100644 index 0000000000..9004e8f337 --- /dev/null +++ b/cypress/integration/forms/CheckboxFieldFF/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 correct diff --git a/cypress/integration/forms/CheckboxFieldFF/can_toggle_a_value/index.js b/cypress/integration/forms/CheckboxFieldFF/can_toggle_a_value/index.js new file mode 100644 index 0000000000..cc927cca28 --- /dev/null +++ b/cypress/integration/forms/CheckboxFieldFF/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.verifyFormArrayValue('checkbox', 'yes') +}) + +Given('a checked Checkbox with a value is rendered', () => { + cy.visitStory('Testing:Checkbox', 'Checked with value') + cy.verifyFormArrayValue('checkbox', 'yes') +}) + +Then('the form value that corresponds to the checkbox will be correct', () => { + cy.window().then(win => { + expect(win.formValues.checkbox).to.deep.equal([]) + }) +}) diff --git a/cypress/integration/forms/CheckboxFieldFF/common/index.js b/cypress/integration/forms/CheckboxFieldFF/common/index.js new file mode 100644 index 0000000000..35729bc887 --- /dev/null +++ b/cypress/integration/forms/CheckboxFieldFF/common/index.js @@ -0,0 +1,5 @@ +import { When } from 'cypress-cucumber-preprocessor/steps' + +When('the user clicks on the Checkbox', () => { + cy.get('[data-test="dhis2-uicore-checkbox"]').click() +}) diff --git a/cypress/integration/forms/CheckboxFieldFF/displays_error.feature b/cypress/integration/forms/CheckboxFieldFF/displays_error.feature new file mode 100644 index 0000000000..e1977c5aed --- /dev/null +++ b/cypress/integration/forms/CheckboxFieldFF/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/CheckboxFieldFF/displays_error/index.js b/cypress/integration/forms/CheckboxFieldFF/displays_error/index.js new file mode 100644 index 0000000000..f4bb77c8a3 --- /dev/null +++ b/cypress/integration/forms/CheckboxFieldFF/displays_error/index.js @@ -0,0 +1,15 @@ +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('[data-test="dhis2-uiwidgets-checkboxfield-validation"]').should( + 'contain', + hasValueMessage + ) +}) diff --git a/cypress/integration/forms/FileInputFieldFF/accepts_file.feature b/cypress/integration/forms/FileInputFieldFF/accepts_file.feature new file mode 100644 index 0000000000..3fcf55c15f --- /dev/null +++ b/cypress/integration/forms/FileInputFieldFF/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/FileInputFieldFF/accepts_file/index.js b/cypress/integration/forms/FileInputFieldFF/accepts_file/index.js new file mode 100644 index 0000000000..5980906212 --- /dev/null +++ b/cypress/integration/forms/FileInputFieldFF/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:FileInput', '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/FileInputFieldFF/common/index.js b/cypress/integration/forms/FileInputFieldFF/common/index.js new file mode 100644 index 0000000000..0f9e80d674 --- /dev/null +++ b/cypress/integration/forms/FileInputFieldFF/common/index.js @@ -0,0 +1,9 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given('a single-file FileInput is rendered', () => { + cy.visitStory('Testing:FileInput', 'Standard form') +}) + +Given('the InputField does not contain any files', () => { + cy.verifyFormValue('fileTxt', undefined) +}) diff --git a/cypress/integration/forms/FileInputFieldFF/displays_error.feature b/cypress/integration/forms/FileInputFieldFF/displays_error.feature new file mode 100644 index 0000000000..dccca1819a --- /dev/null +++ b/cypress/integration/forms/FileInputFieldFF/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/FileInputFieldFF/displays_error/index.js b/cypress/integration/forms/FileInputFieldFF/displays_error/index.js new file mode 100644 index 0000000000..d1f016376c --- /dev/null +++ b/cypress/integration/forms/FileInputFieldFF/displays_error/index.js @@ -0,0 +1,16 @@ +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') + .get('[data-test="dhis2-uiwidgets-fileinputfield-validation"]') + .should( + 'contain', + 'The file you provided is not a txt file, received "md"' + ) +}) diff --git a/cypress/integration/forms/InputFieldFF/can_set_a_value.feature b/cypress/integration/forms/InputFieldFF/can_set_a_value.feature new file mode 100644 index 0000000000..6dc317bdbb --- /dev/null +++ b/cypress/integration/forms/InputFieldFF/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/InputFieldFF/can_set_a_value/index.js b/cypress/integration/forms/InputFieldFF/can_set_a_value/index.js new file mode 100644 index 0000000000..4131048d89 --- /dev/null +++ b/cypress/integration/forms/InputFieldFF/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:InputFieldFF', '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/InputFieldFF/displays_error.feature b/cypress/integration/forms/InputFieldFF/displays_error.feature new file mode 100644 index 0000000000..c9b6515fe4 --- /dev/null +++ b/cypress/integration/forms/InputFieldFF/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/InputFieldFF/displays_error/index.js b/cypress/integration/forms/InputFieldFF/displays_error/index.js new file mode 100644 index 0000000000..bd73690082 --- /dev/null +++ b/cypress/integration/forms/InputFieldFF/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:InputFieldFF', 'Required') + cy.verifyFormValue('agree', undefined) +}) + +Then('an error message is shown', () => { + cy.get('.error').should('contain', hasValueMessage) +}) diff --git a/cypress/integration/forms/MultiSelectFieldFF/can_set_a_value.feature b/cypress/integration/forms/MultiSelectFieldFF/can_set_a_value.feature new file mode 100644 index 0000000000..4111f14519 --- /dev/null +++ b/cypress/integration/forms/MultiSelectFieldFF/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/MultiSelectFieldFF/can_set_a_value/index.js b/cypress/integration/forms/MultiSelectFieldFF/can_set_a_value/index.js new file mode 100644 index 0000000000..81f536b241 --- /dev/null +++ b/cypress/integration/forms/MultiSelectFieldFF/can_set_a_value/index.js @@ -0,0 +1,43 @@ +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('[data-test="dhis2-uicore-multiselect"]').click() + cy.get('[data-test="dhis2-uicore-checkbox"]') + .first() + .click() +}) + +When('the user selects the second option', () => { + cy.get('[data-test="dhis2-uicore-checkbox"]') + .last() + .click() +}) + +Then("the form state's value equals the first option's value", () => { + cy.getFormValue('multiSelect').then(selected => { + expect(selected).to.have.lengthOf(1) + expect(selected).to.deep.equal(['value1']) + }) +}) + +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(['value1', 'value2']) + }) + }) +}) diff --git a/cypress/integration/forms/MultiSelectFieldFF/common/index.js b/cypress/integration/forms/MultiSelectFieldFF/common/index.js new file mode 100644 index 0000000000..51de51f5fe --- /dev/null +++ b/cypress/integration/forms/MultiSelectFieldFF/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:MultiSelectFieldFF', 'Required') + cy.getFormValue('multiSelect') + cy.verifyFormValue('multiSelect', undefined) +}) diff --git a/cypress/integration/forms/MultiSelectFieldFF/displays_error.feature b/cypress/integration/forms/MultiSelectFieldFF/displays_error.feature new file mode 100644 index 0000000000..b37ae2f960 --- /dev/null +++ b/cypress/integration/forms/MultiSelectFieldFF/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/MultiSelectFieldFF/displays_error/index.js b/cypress/integration/forms/MultiSelectFieldFF/displays_error/index.js new file mode 100644 index 0000000000..1836888f28 --- /dev/null +++ b/cypress/integration/forms/MultiSelectFieldFF/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/RadioFieldFF/can_set_a_value.feature b/cypress/integration/forms/RadioFieldFF/can_set_a_value.feature new file mode 100644 index 0000000000..d86bd11663 --- /dev/null +++ b/cypress/integration/forms/RadioFieldFF/can_set_a_value.feature @@ -0,0 +1,7 @@ +Feature: The RadioFieldFF can set a value + + Scenario: The user clicks the first option + Given a FieldGroupFF with required RadioFieldFFs and no selected value + And there are three options + When the user selects the last option + Then the form state's value equals the last option's value diff --git a/cypress/integration/forms/RadioFieldFF/can_set_a_value/index.js b/cypress/integration/forms/RadioFieldFF/can_set_a_value/index.js new file mode 100644 index 0000000000..30ef84eb3f --- /dev/null +++ b/cypress/integration/forms/RadioFieldFF/can_set_a_value/index.js @@ -0,0 +1,25 @@ +import '../common/index.js' +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('there are three options', () => { + const options = [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + { value: 'three', label: 'Three' }, + ] + + cy.wrap(options).as('options') + cy.window().then(win => { + win.updateCypressProps({ options }) + }) +}) + +When('the user selects the last option', () => { + cy.get('label:last').click() +}) + +Then("the form state's value equals the last option's value", () => { + cy.get('@options').then(options => { + cy.verifyFormValue('choice', options[2].value) + }) +}) diff --git a/cypress/integration/forms/RadioFieldFF/common/index.js b/cypress/integration/forms/RadioFieldFF/common/index.js new file mode 100644 index 0000000000..ab1d803727 --- /dev/null +++ b/cypress/integration/forms/RadioFieldFF/common/index.js @@ -0,0 +1,9 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'a FieldGroupFF with required RadioFieldFFs and no selected value', + () => { + cy.visitStory('Testing:RadioFieldFF', 'Required and no selected value') + cy.verifyFormValue('choice', undefined) + } +) diff --git a/cypress/integration/forms/RadioFieldFF/displays_error.feature b/cypress/integration/forms/RadioFieldFF/displays_error.feature new file mode 100644 index 0000000000..d3c786af3e --- /dev/null +++ b/cypress/integration/forms/RadioFieldFF/displays_error.feature @@ -0,0 +1,6 @@ +Feature: The RadioFieldFF field displays an error when invalid + + Scenario: Form is submitted with none of the options selected + Given a FieldGroupFF with required RadioFieldFFs and no selected value + When the user submits the form + Then an error message is shown diff --git a/cypress/integration/forms/RadioFieldFF/displays_error/index.js b/cypress/integration/forms/RadioFieldFF/displays_error/index.js new file mode 100644 index 0000000000..d6d0ee17cf --- /dev/null +++ b/cypress/integration/forms/RadioFieldFF/displays_error/index.js @@ -0,0 +1,11 @@ +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('[data-test="dhis2-uicore-field-validation"]').should( + 'contain', + hasValueMessage + ) +}) diff --git a/cypress/integration/forms/SingleSelectFieldFF/can_set_a_value.feature b/cypress/integration/forms/SingleSelectFieldFF/can_set_a_value.feature new file mode 100644 index 0000000000..03a7ef96e7 --- /dev/null +++ b/cypress/integration/forms/SingleSelectFieldFF/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/SingleSelectFieldFF/can_set_a_value/index.js b/cypress/integration/forms/SingleSelectFieldFF/can_set_a_value/index.js new file mode 100644 index 0000000000..56ba22bb13 --- /dev/null +++ b/cypress/integration/forms/SingleSelectFieldFF/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('[data-test="dhis2-uicore-select-input"]').selectSelectNthOption(0) +}) + +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].value) + }) + }) +}) diff --git a/cypress/integration/forms/SingleSelectFieldFF/common/index.js b/cypress/integration/forms/SingleSelectFieldFF/common/index.js new file mode 100644 index 0000000000..ccf13fe5a5 --- /dev/null +++ b/cypress/integration/forms/SingleSelectFieldFF/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:SingleSelectFieldFF', 'Required') + cy.verifyFormValue('singleSelect', undefined) +}) diff --git a/cypress/integration/forms/SingleSelectFieldFF/displays_error.feature b/cypress/integration/forms/SingleSelectFieldFF/displays_error.feature new file mode 100644 index 0000000000..d10c7b9a7a --- /dev/null +++ b/cypress/integration/forms/SingleSelectFieldFF/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/SingleSelectFieldFF/displays_error/index.js b/cypress/integration/forms/SingleSelectFieldFF/displays_error/index.js new file mode 100644 index 0000000000..1836888f28 --- /dev/null +++ b/cypress/integration/forms/SingleSelectFieldFF/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/SwitchFieldFF/can_toggle_a_boolean.feature b/cypress/integration/forms/SwitchFieldFF/can_toggle_a_boolean.feature new file mode 100644 index 0000000000..4f875893c3 --- /dev/null +++ b/cypress/integration/forms/SwitchFieldFF/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/SwitchFieldFF/can_toggle_a_boolean/index.js b/cypress/integration/forms/SwitchFieldFF/can_toggle_a_boolean/index.js new file mode 100644 index 0000000000..e349eaf71d --- /dev/null +++ b/cypress/integration/forms/SwitchFieldFF/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:SwitchFieldFF', '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:SwitchFieldFF', '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/SwitchFieldFF/can_toggle_a_value.feature b/cypress/integration/forms/SwitchFieldFF/can_toggle_a_value.feature new file mode 100644 index 0000000000..b60abf0a11 --- /dev/null +++ b/cypress/integration/forms/SwitchFieldFF/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 correct diff --git a/cypress/integration/forms/SwitchFieldFF/can_toggle_a_value/index.js b/cypress/integration/forms/SwitchFieldFF/can_toggle_a_value/index.js new file mode 100644 index 0000000000..15bf145a34 --- /dev/null +++ b/cypress/integration/forms/SwitchFieldFF/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:SwitchFieldFF', 'Unchecked with value') + cy.verifyFormValue('switch', undefined) +}) + +Then('the form value that corresponds to the switch will be yes', () => { + cy.verifyFormArrayValue('switch', 'yes') +}) + +Given('a checked Switch with a value is rendered', () => { + cy.visitStory('Testing:SwitchFieldFF', 'Checked with value') + cy.verifyFormArrayValue('switch', 'yes') +}) + +Then('the form value that corresponds to the switch will be correct', () => { + cy.window().then(win => { + expect(win.formValues.switch).to.deep.equal([]) + }) +}) diff --git a/cypress/integration/forms/SwitchFieldFF/common/index.js b/cypress/integration/forms/SwitchFieldFF/common/index.js new file mode 100644 index 0000000000..bd1eb5aa40 --- /dev/null +++ b/cypress/integration/forms/SwitchFieldFF/common/index.js @@ -0,0 +1,5 @@ +import { When } from 'cypress-cucumber-preprocessor/steps' + +When('the user clicks on the Switch', () => { + cy.get('[data-test="dhis2-uicore-switch"]').click() +}) diff --git a/cypress/integration/forms/SwitchFieldFF/displays_error.feature b/cypress/integration/forms/SwitchFieldFF/displays_error.feature new file mode 100644 index 0000000000..45f317d746 --- /dev/null +++ b/cypress/integration/forms/SwitchFieldFF/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/SwitchFieldFF/displays_error/index.js b/cypress/integration/forms/SwitchFieldFF/displays_error/index.js new file mode 100644 index 0000000000..999ca52b1d --- /dev/null +++ b/cypress/integration/forms/SwitchFieldFF/displays_error/index.js @@ -0,0 +1,15 @@ +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:SwitchFieldFF', 'Unchecked') + cy.verifyFormValue('switch', undefined) +}) + +Then('an error message is shown', () => { + cy.get('[data-test="dhis2-uiwidgets-switchfield-validation"]').should( + 'contain', + hasValueMessage + ) +}) diff --git a/cypress/integration/forms/TextAreaFieldFF/can_set_a_value.feature b/cypress/integration/forms/TextAreaFieldFF/can_set_a_value.feature new file mode 100644 index 0000000000..3e96e6a991 --- /dev/null +++ b/cypress/integration/forms/TextAreaFieldFF/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/TextAreaFieldFF/can_set_a_value/index.js b/cypress/integration/forms/TextAreaFieldFF/can_set_a_value/index.js new file mode 100644 index 0000000000..1ebdc266e2 --- /dev/null +++ b/cypress/integration/forms/TextAreaFieldFF/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/TextAreaFieldFF/displays_error.feature b/cypress/integration/forms/TextAreaFieldFF/displays_error.feature new file mode 100644 index 0000000000..6a98f40135 --- /dev/null +++ b/cypress/integration/forms/TextAreaFieldFF/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/TextAreaFieldFF/displays_error/index.js b/cypress/integration/forms/TextAreaFieldFF/displays_error/index.js new file mode 100644 index 0000000000..cfbb2427a9 --- /dev/null +++ b/cypress/integration/forms/TextAreaFieldFF/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/all.js b/cypress/support/all.js new file mode 100644 index 0000000000..c7006f2e49 --- /dev/null +++ b/cypress/support/all.js @@ -0,0 +1,17 @@ +/** + * Copied (and slightly modified) from here: + * https://github.com/cypress-io/cypress/issues/915#issuecomment-344389511 + * + * This works because cypress commands are not promises. + * They're executed sequentially, never in parallel. + * + * See here: https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Commands-Are-Not-Promises + * > You cannot race or run multiple commands at the same time (in parallel). + */ +const all = (...commands) => { + const results = [] + commands.forEach(command => command().then(result => results.push(result))) + return cy.wrap(results) +} + +Cypress.Commands.add('all', all) diff --git a/cypress/support/clickWith.js b/cypress/support/clickWith.js new file mode 100644 index 0000000000..2e2e996b59 --- /dev/null +++ b/cypress/support/clickWith.js @@ -0,0 +1,21 @@ +/** + * See https://docs.cypress.io/api/commands/click.html#Click-with-key-combinations + * for why doing the follwoing is necessary: + * + * `cy.get('body').type(`{...}`, { release: false })` + */ +const clickWith = (subject, modifierKey, options) => { + // the "cmd" key is called "meta" in js + const keyName = modifierKey === 'cmd' ? 'meta' : modifierKey + + cy.get('body').type(`{${keyName}}`, { release: false }) + cy.wrap(subject).click(options) + + // The key is only released after the test is over, + // releasing it manually fixed that issue + cy.get('body').type(`{${keyName}}`, { release: true }) + + return cy.wrap(subject) +} + +Cypress.Commands.add('clickWith', { prevSubject: true }, clickWith) diff --git a/cypress/support/common/dataTestNameToSelector.js b/cypress/support/common/dataTestNameToSelector.js new file mode 100644 index 0000000000..8714a775d9 --- /dev/null +++ b/cypress/support/common/dataTestNameToSelector.js @@ -0,0 +1,12 @@ +/** + * @param {string} dataTestName + * @param {string} [prefix] - Default to "dhis2-uicore" + * @returns {string} + */ +export const dataTestNameToSelector = ( + dataTestName, + prefix = 'dhis2-uicore' +) => { + const dataTestId = prefix ? `${prefix}-${dataTestName}` : dataTestName + return `[data-test="${dataTestId}"]` +} diff --git a/cypress/support/common/parseSelectorWithDataTest.js b/cypress/support/common/parseSelectorWithDataTest.js new file mode 100644 index 0000000000..bf92d06b33 --- /dev/null +++ b/cypress/support/common/parseSelectorWithDataTest.js @@ -0,0 +1,7 @@ +import { dataTestNameToSelector } from './dataTestNameToSelector' + +export const parseSelectorWithDataTest = (selector, prefix) => { + return selector.replace(/\{([^}]*)\}/g, (match, dataTestName) => + dataTestNameToSelector(dataTestName, prefix) + ) +} diff --git a/cypress/support/find.js b/cypress/support/find.js new file mode 100644 index 0000000000..bbf8c1a806 --- /dev/null +++ b/cypress/support/find.js @@ -0,0 +1,26 @@ +import { parseSelectorWithDataTest } from './common/parseSelectorWithDataTest' + +/** + * Takes a space-separated list of dataTestIds and wraps & prefixes them + * @param {jQuery} subject + * @param {string} selectors + * @param {string} [prefix] + * @returns {Object} + * + * @example + * //These are equivalent: + * cy.get('.foo').find('{node-filter} input') + * cy.get('.foo').find('[data-test="dhis2-uicore-node-filter"] input') + * + * //These as well: + * cy.get('.foo').find('{node} input', 'custom-prefix') + * cy.get('.foo').find('[data-test="custom-prefix-node"] input') + */ +// eslint-disable-next-line max-params +const find = (originalFn, subject, selectors, options = {}) => { + const { prefix, ...restOptions } = options + const selector = parseSelectorWithDataTest(selectors, prefix) + return originalFn(subject, selector, restOptions) +} + +Cypress.Commands.overwrite('find', find) diff --git a/cypress/support/get.js b/cypress/support/get.js new file mode 100644 index 0000000000..d005055130 --- /dev/null +++ b/cypress/support/get.js @@ -0,0 +1,24 @@ +import { parseSelectorWithDataTest } from './common/parseSelectorWithDataTest' + +/** + * Takes a space-separated list of dataTestIds and wraps & prefixes them + * @param {string} selectors + * @param {string} [prefix] + * @returns {Object} + * + * @example + * //These are equivalent: + * cy.get('{node-filter} input') + * cy.get('[data-test="dhis2-uicore-node-filter"] input') + * + * //These as well: + * cy.get('{node} {node} input', 'custom-prefix') + * cy.get('[data-test="custom-prefix-node"] [data-test="custom-prefix-node"] input') + */ +const get = (originalFn, selectors, options = {}) => { + const { prefix, ...restOptions } = options + const selector = parseSelectorWithDataTest(selectors, prefix) + return originalFn(selector, restOptions) +} + +Cypress.Commands.overwrite('get', get) diff --git a/cypress/support/getAll.js b/cypress/support/getAll.js new file mode 100644 index 0000000000..e829a044de --- /dev/null +++ b/cypress/support/getAll.js @@ -0,0 +1,9 @@ +Cypress.Commands.add('getAll', (...elements) => { + const promise = cy.wrap([], { log: false }) + + for (const element of elements) { + promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got]))) + } + + return promise +}) 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..a4f6c3231c --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,16 @@ +import '@dhis2/cli-utils-cypress/support' + +// Add additional support functions here +import './all' +import './clickWith' +import './find' +import './get' +import './getAll' +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..be7f49826c --- /dev/null +++ b/cypress/support/selectSelectNthOption.js @@ -0,0 +1,16 @@ +function selectSelectNthOption(subject, index, closeMenu = false) { + cy.wrap(subject).click() + cy.get('[data-test="dhis2-uicore-singleselectoption"]') + .eq(index) + .click() + + if (closeMenu) { + cy.get('.layer').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..689f4dfc74 --- /dev/null +++ b/cypress/support/verifyFormValue.js @@ -0,0 +1,11 @@ +Cypress.Commands.add('verifyFormValue', (fieldName, expectedValue) => { + cy.getFormValue(fieldName).then(actualValue => { + expect(actualValue).to.equal(expectedValue) + }) +}) + +Cypress.Commands.add('verifyFormArrayValue', (fieldName, expectedValue) => { + cy.getFormValue(fieldName).then(actualValue => { + expect(actualValue).to.contain(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..8c7d923969 --- /dev/null +++ b/docs/_sidebar.md @@ -0,0 +1,5 @@ +- [**Getting started**](getting-started) +- [**API**](api) +- [**Concepts**](concepts) +- [**Live demos**](/demo/ ':ignore Live demos') +- [**Troubleshooting**](troubleshooting) diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000000..da422e4882 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,201 @@ +# Conceptual design of @dhis2/ui + +## :warning: UNDER CONSTRUCTION :warning: + +The alpha version is still under construction, these are the steps we need to +turn go get this version into a stable one: + +- [x] Move components from `ui-core` & `ui-widget` to the `ui` monorepo +- [ ] Add breaking changes +- [ ] Re-organize library + +## Monorepo + +UI is a monorepo that contains all the UI-related packages for DHIS 2. + +### Why a monorepo instead of one repository + +The design-system, ui-core, and ui-widgets are separate by design, and the +boundaries between them is a valuable trait of the system that pays off in the +long term. + +Those three libraries are kept separate because they are three distinct things. +On a technical level we work with all off them, and sometimes it feels like we +need to work on all of them at once, so the best thing to do is to combine them +into one thing. + +This is a fallacy of perception, we need to "zoom out" to understand the scope +of each individual library and how they fit into the global DHIS 2 developer +ecosystem. + +We have 15 core developers, but there are hundreds to thousands of external +developers building DHIS 2 apps. + +### Reasons for having switched from several repositories to a monorepo + +#### Problem: Developer convenience + +We can move components from core to widgets and maintain a proper split between +all the UI libraries, and the drawback is when developing a molecule that needs +new atoms in core, then they first have be merged to core or the developer has +to link the libraries locally. Matters are made worse if the developer is working on +a form component that needs something in widgets and that triggers work in +core. + +In a monorepo we can have all the code organized properly in a single PR, and +the developer is not "held back" by either local development tricks like links +nor waiting for a PR. + +#### Problem: How can I find the component I need? + +> (Ideally I think we would have a way of organizing our components that is +> clear to us and our users. So that neither groups have to do too much +> thinking about what belongs/can be found where.) + +The **@dhis2/ui** meta-package re-exports all of our `ui-*` libraries under a +single namespace, so a user can either install e.g. @dhis2/ui-core +specifically, but more likely, the user will install @dhis2/ui and then use it: + +#### Problem: Version consistency + +Right now, consolidating the UI-core versions across apps, the app shell, ui-forms, +ui-widgets, etc. can be tricky. We do some tricks to make ui-core external and +use it as a peer dependency, but if someone has it as a straight dependency there is a +possibility that ui-core becomes out of sync between app and ui-widgets, and +can result in unpredictable behavior. If we at least lock the versions together +in a monorepo, then it becomes easier to debug and resolve those errors. + +## Main package + +**@dhis2/ui** is the main package, and is a meta-package that wraps and +re-exports relevant packages in the UI suite. + +This is the main entrypoint that an application should import and use. + +``` +import { + Button, + OrgUnitTree, + HeaderBar, +} from '@dhis2/ui' +``` + +## Sub-packages + +### Constants + +**@dhis2/ui-constants** provides access to UI-related constants, such as +color, theme, spacer, and elevation values that are used across the UI packages +as well as in applications that need custom components for example. + +### PropTypes + +The @dhis2/prop-types library does not force development practices on +developers, they can be used pretty much anywhere. This is the "have it your +way" libraries. + +### Icons + +The **@dhis2/ui-icons** library does not force development practices on +developers, they can be used pretty much anywhere. This is the "have it your +way" libraries. + +### Core + +The **@dhis2/ui-core** library is the reference implementation of the +**building blocks** in the design system. These components are pure style and +structure and should be usable by any application that satisfies the +requirements, no matter if it is a DHIS 2 App or something entirely different. + +### Widgets + +Currently the @dhis2/ui-widgets does not have a specific purpose, but combines +everything that does not fit into either one of the above or one of the more +specialized libraries down below. The following list contains thematic +concepts that can be found in the widgets sub-package. **This will likely be +subject to change in the near future. There's no clear idea of how to divide +these components or what sub-packages we want to have. But regardless of what +conclusion we'll arrive at, they'll always be exported from the `@dhis2/ui` +library**. + +It's also noteworthy that many components in `widgets` combine several of the +following thematic concepts, which complicates refactoring these into several +libraries. + +#### Built-in DHIS 2-Api + +Some components, like the `HeaderBar`, request specific DHIS 2 endpoints. +This limits the use case to DHIS 2 apps and therefore needs to be separated +from the libraries listed above. + +#### DHIS 2 data structure + +Just like components using DHIS 2 endpoints can be used in DHIS 2 apps only, +some components, albeit not requesting any data, operate on data that's +specific to DHIS 2 application. + +#### Translations + +We consider translations to be very similar to DHIS 2 data. As translations +always convey a specific meaning. + +#### Complex components + +Some of our components are quite complex (`Transfer`, `*Field`). +These are not really building blocks anymore and have a very opinionated +structure built into them, so we keep them separate from our more composable +components in `core`. + +#### External dependencies + +A "widget" can also be a component which requires specific external +dependencies to function, where-as our core components should never require +additional dependencies to function. + +#### DHIS 2 development practices + +At the "widget" level we are forcing our development practices on developers, +so if they want to use our complex components, they need to be on-board with +that (e.g. the DataProvider pattern, React Hooks). +If they do not want that, they can drop down to ui-core, where we do not force +our way of building applications much more than a HTML component forces it. + +### Forms + +The **@dhis2/ui-forms** library has two purposes: + +#### Bridge between our components and React Final Form + +All our `*Field` components can be found in this repository as well. +Functional-wise they're the same as the other components but the api has been +adjusted to work with React Final Form, so you can just provided them via the +`component={*Field}` prop and everything else (expect the `type` prop) will be +taken care of. + +#### Form validators + +We also offer a (so far) limited set of validator functions that work with +React Final Form as well, but they could be used in other contexts as well. + +## Development + +### What package should new components be added to + +The distinction between what a core component is and a non-core component is +lies at the center of many things, not least what goes where. + +The scopes of ui-core and ui-widgets are inverted. The bar to add components to +ui-core should be high, and the bar to add components to ui-widgets should be +low. + +## Future + +We just moved to a monorepo and we're expecting some breaking changes in the +near future. There are some issues we have with the current organization of our +libraries but we don't want to start over again and again. + +The monorepo will allow us to change the internal organization of the code +while not introducing a breaking change in the `ui` repository. + +Our main goal is to change the `widgets` package as it does not have a clear +purpose but rather is a collection of components that don't fit anywhere else. 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/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000000..2f49be2d28 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,7 @@ +# Troubleshooting + +## Encountering z-index issues? + +To ensure that components are stacked on top of each other correctly, a Layer component is used which leverages a React context. If an application is using multiple instances of `@dhis2/ui-core`, it will end up using multiple instances of this context as well. As a result components will not be stacked on top of each other correctly. + +To prevent this issue, make sure that the app and its dependencies are using compatible version ranges of `@dhis2/ui`, so `yarn` is able to deduplicate these versions. You can assess if this is in fact the case by running `yarn why @dhis2/ui-core`. See the [yarn docs](https://classic.yarnpkg.com/en/docs/cli/why/) for more information. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000000..5fcc95aedf --- /dev/null +++ b/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + rootDir: '.', + setupFilesAfterEnv: ['config/jest/enzymeSetup.js'], + roots: ['/packages'], + testPathIgnorePatterns: [ + '/node_modules/', + 'Transfer/__tests__/common/createChildren.js', + ], +} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000000..2f1f2fa95f --- /dev/null +++ b/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "yarn install --frozen-lockfile && yarn build && yarn docs && yarn demo" + publish = "dist/" diff --git a/package.json b/package.json new file mode 100644 index 0000000000..dc1399797c --- /dev/null +++ b/package.json @@ -0,0 +1,72 @@ +{ + "name": "root", + "version": "5.0.0-alpha.21", + "repository": { + "type": "git", + "url": "https://github.com/dhis2/ui.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.9", + "@dhis2/cli-style": "^7.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": "^2.1.1", + "@storybook/react": "^5.3.17", + "@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.1", + "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..bfaf0a76cf --- /dev/null +++ b/packages/constants/package.json @@ -0,0 +1,27 @@ +{ + "name": "@dhis2/ui-constants", + "version": "5.0.0-alpha.21", + "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.6.4" + }, + "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..00d7ca6871 --- /dev/null +++ b/packages/constants/src/shared-prop-types.js @@ -0,0 +1,82 @@ +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 +) + +/** + * 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 popperPlacementPropType = 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 DOM node, React ref or a virtual element + * @return {PropType} Validate that prop is either a function or an + * instance of an Element. + */ +export const popperReferencePropType = propTypes.oneOfType([ + // DOM node + propTypes.instanceOf(Element), + // React ref - React.useRef() or React.createRef() + propTypes.shape({ current: propTypes.instanceOf(Element) }), + // Virtual element + propTypes.shape({ getBoundingClientRect: propTypes.func }), +]) 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..4c4b1183e0 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,791 @@ +## [4.21.1](https://github.com/dhis2/ui-core/compare/v4.21.0...v4.21.1) (2020-05-19) + + +### Bug Fixes + +* prevent icon from shrinking when label text overflows ([ccd86bc](https://github.com/dhis2/ui-core/commit/ccd86bc269a1dc2a6121388c465f59a3b0705cbb)) + +# [4.21.0](https://github.com/dhis2/ui-core/compare/v4.20.0...v4.21.0) (2020-05-19) + + +### Features + +* add support for virtual elements in popper and popover ([3a06526](https://github.com/dhis2/ui-core/commit/3a065266f390501eb8cb606fe84f7ba81621c0dc)) + +# [4.20.0](https://github.com/dhis2/ui-core/compare/v4.19.1...v4.20.0) (2020-05-18) + + +### Features + +* let popper and popover accept react-refs or dom nodes directly ([df11f3c](https://github.com/dhis2/ui-core/commit/df11f3ca20d31094d36d3b7965d61364947f62ee)) + +## [4.19.1](https://github.com/dhis2/ui-core/compare/v4.19.0...v4.19.1) (2020-04-30) + + +### Bug Fixes + +* **tooltip:** allow either function or node as children ([cecfc16](https://github.com/dhis2/ui-core/commit/cecfc162d7c78e26270ff20310cef1734dd2763b)) + +# [4.19.0](https://github.com/dhis2/ui-core/compare/v4.18.0...v4.19.0) (2020-04-24) + + +### Features + +* **transfer:** add component ([aaeaaa3](https://github.com/dhis2/ui-core/commit/aaeaaa3f60b793b85ef93d8647dd0c056e2c8c9e)) + +# [4.18.0](https://github.com/dhis2/ui-core/compare/v4.17.1...v4.18.0) (2020-04-22) + + +### Features + +* **tooltip:** introduce tooltip as a public component ([158a322](https://github.com/dhis2/ui-core/commit/158a322719e6e4515212bdbb5c0bb05b91e94656)) + +## [4.17.1](https://github.com/dhis2/ui-core/compare/v4.17.0...v4.17.1) (2020-04-15) + + +### Bug Fixes + +* **MultiSelect:** use dense chips for selections in input ([a3b9e20](https://github.com/dhis2/ui-core/commit/a3b9e20bb4317aa2fbd1f7c5c78692fc11844160)) + +# [4.17.0](https://github.com/dhis2/ui-core/compare/v4.16.0...v4.17.0) (2020-04-14) + + +### Bug Fixes + +* **chip:** adjust remove icon ([942265f](https://github.com/dhis2/ui-core/commit/942265f9917246b1a4a11420c8c367b2bb7517ba)) + + +### Features + +* **chip:** dense style ([9d7cb52](https://github.com/dhis2/ui-core/commit/9d7cb52bee32110c0bba2cba9d3671ec2b1f3da7)) + +# [4.16.0](https://github.com/dhis2/ui-core/compare/v4.15.0...v4.16.0) (2020-03-25) + + +### Bug Fixes + +* **tooltip:** remove `tag` prop so anchor is always a span ([0c4a72a](https://github.com/dhis2/ui-core/commit/0c4a72ad61f043fc8406e2a385a375bc2ae22e95)) + + +### Features + +* tooltip with renderprops ([3b5aeca](https://github.com/dhis2/ui-core/commit/3b5aecabe202c61041ecde40617563aacb303e5a)) + +# [4.15.0](https://github.com/dhis2/ui-core/compare/v4.14.0...v4.15.0) (2020-03-19) + + +### Features + +* introduce tooltip and e2e tests ([bec3f07](https://github.com/dhis2/ui-core/commit/bec3f0717840aa466ba00dafd0f499fca5dd3e63)) + +# [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..a5d6a37a31 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,38 @@ +{ + "name": "@dhis2/ui-core", + "description": "Component primitives for DHIS2 user interfaces", + "version": "5.0.0-alpha.21", + "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.6.4", + "@dhis2/ui-constants": "5.0.0-alpha.21", + "@dhis2/ui-icons": "5.0.0-alpha.21", + "@popperjs/core": "^2.1.0", + "classnames": "^2.2.6", + "react-popper": "^2.2.3", + "resize-observer-polyfill": "^1.5.1" + }, + "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..80901d1a50 --- /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('Components/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..774df0f02b --- /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('Components/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/Box/Box.js b/packages/core/src/Box/Box.js new file mode 100644 index 0000000000..1e5df2fb3c --- /dev/null +++ b/packages/core/src/Box/Box.js @@ -0,0 +1,69 @@ +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, + marginTop, + children, + dataTest, + className, +}) => ( +
+ {children} + +
+) + +Box.defaultProps = { + dataTest: 'dhis2-uicore-box', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [className] + * @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, + className: propTypes.string, + dataTest: propTypes.string, + height: propTypes.string, + marginTop: 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..7fe23ee12e --- /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: 'Components/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..337549badc --- /dev/null +++ b/packages/core/src/Button/Button.js @@ -0,0 +1,158 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React, { Component, createRef } from 'react' + +import { sharedPropTypes } from '@dhis2/ui-constants' + +import ButtonBase from '../ButtonBase/ButtonBase.js' + +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 ( + + {icon && {icon}} + {children} + + + ) + } +} + +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..0c66e0e91c --- /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: 'Components/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 = () => ( + + ) +) + +ButtonBase.displayName = 'ButtonBase' + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * @prop {string} [className] + * @prop {boolean} [small] + * @prop {boolean} [large] + * @prop {boolean } [primary] + * @prop {boolean } [secondary] + * @prop {boolean } [destructive] + * @prop {boolean} [disabled] + */ +ButtonBase.propTypes = { + children: propTypes.node, + className: propTypes.string, + destructive: propTypes.bool, + disabled: propTypes.bool, + large: propTypes.bool, + primary: propTypes.bool, + secondary: propTypes.bool, + small: propTypes.bool, +} + +export default ButtonBase diff --git a/packages/core/src/ButtonBase/ButtonBase.stories.js b/packages/core/src/ButtonBase/ButtonBase.stories.js new file mode 100644 index 0000000000..29e2383248 --- /dev/null +++ b/packages/core/src/ButtonBase/ButtonBase.stories.js @@ -0,0 +1,18 @@ +import React from 'react' + +import ButtonBase from './ButtonBase.js' + +export default { + title: 'Components/Base/ButtonBase', + component: ButtonBase, +} + +export const Default = () => Default button +export const Primary = () => Default button +export const Secondary = () => Default button +export const Destructive = () => ( + Default button +) +export const Small = () => Default button +export const Large = () => Default button +export const Disabled = () => Default button diff --git a/packages/core/src/ButtonBase/ButtonBase.styles.js b/packages/core/src/ButtonBase/ButtonBase.styles.js new file mode 100644 index 0000000000..61a28da409 --- /dev/null +++ b/packages/core/src/ButtonBase/ButtonBase.styles.js @@ -0,0 +1,223 @@ +import css from 'styled-jsx/css' + +import { colors, theme, spacers } from '@dhis2/ui-constants' + +export default css` + button { + display: inline-flex; + position: relative; + align-items: center; + justify-content: center; + border-radius: 4px; + font-weight: 400; + letter-spacing: 0.5px; + text-decoration: none; + cursor: pointer; + transition: all 0.15s cubic-bezier(0.4, 0, 0.6, 1); + user-select: none; + color: ${colors.grey900}; + + /*medium*/ + height: 36px; + padding: 0 ${spacers.dp12}; + font-size: 14px; + line-height: 16px; + + /*basic*/ + border: 1px solid ${colors.grey500}; + background-color: #f9fafb; + } + + button:disabled { + cursor: not-allowed; + } + + button:focus { + /* Prevent default browser behavior which adds an outline */ + outline: none; + } + + /* Use the ::after pseudo-element for focus styles */ + button::after { + content: ' '; + pointer-events: none; + + position: absolute; + top: -2px; + right: -2px; + bottom: -2px; + left: -2px; + z-index: 1; + + border: 2px solid transparent; + border-radius: inherit; + + transition: border-color 0.15s cubic-bezier(0.4, 0, 0.6, 1); + } + + /* Prevent focus styles on active and disabled buttons */ + button:active:focus::after, + button:disabled:focus::after { + border-color: transparent; + } + + button:hover { + border-color: ${colors.grey500}; + background-color: ${colors.grey200}; + } + + button:active, + button:active:focus { + border-color: ${colors.grey500}; + background-color: ${colors.grey200}; + box-shadow: 0 0 0 1px rgb(0, 0, 0, 0.1) inset; + } + + button:focus { + background-color: #f9fafb; + } + + button:focus::after { + border-color: ${theme.primary600}; + } + + button:disabled { + border-color: ${colors.grey400}; + background-color: #f9fafb; + box-shadow: none; + color: ${theme.disabled}; + fill: ${theme.disabled}; + } + + .small { + height: 28px; + padding: 0 8px; + font-size: 14px; + line-height: 16px; + } + + .large { + height: 43px; + padding: 0 ${spacers.dp24}; + font-size: 16px; + letter-spacing: 0.57px; + line-height: 19px; + } + + .primary { + border-color: ${theme.primary800}; + background: linear-gradient(180deg, #1565c0 0%, #0650a3 100%); + background-color: #2b61b3; + color: ${colors.white}; + fill: ${colors.white}; + font-weight: 500; + } + + .primary:hover { + border-color: ${theme.primary800}; + background: linear-gradient(180deg, #054fa3 0%, #034793 100%); + background-color: #21539f; + } + + .primary:active, + .primary:active:focus { + border-color: ${theme.primary800}; + background: linear-gradient(180deg, #054fa3 0%, #034793 100%); + background-color: #1c4a90; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18) inset; + } + + .primary:focus { + background: linear-gradient(180deg, #1565c0 0%, #0650a3 100%); + background-color: #285dac; + } + + .primary:focus::after { + border-color: ${colors.yellow300}; + } + + .primary:disabled { + border-color: ${theme.primary800}; + background: linear-gradient(180deg, #1565c0 0%, #0650a3 100%); + background-color: #b6c8e2; + box-shadow: none; + color: ${colors.white}; + fill: ${colors.white}; + opacity: 0.33; + } + + .secondary { + border-color: ${colors.grey400}; + background-color: transparent; + } + + .secondary:hover { + border-color: ${colors.grey400}; + background-color: rgba(160, 173, 186, 0.08); + } + + .secondary:active, + .secondary:active:focus { + border-color: ${colors.grey400}; + background-color: rgba(160, 173, 186, 0.2); + box-shadow: none; + } + + .secondary:focus { + background-color: transparent; + } + + .secondary:focus::after { + border-color: ${theme.primary600}; + } + + .secondary:disabled { + border-color: ${colors.grey400}; + background-color: transparent; + box-shadow: none; + color: ${theme.disabled}; + fill: ${theme.disabled}; + } + + .destructive { + border-color: #a10b0b; + background: linear-gradient(180deg, #d32f2f 0%, #b71c1c 100%); + background-color: #b9242b; + color: ${colors.white}; + fill: ${colors.white}; + font-weight: 500; + } + + .destructive:hover { + border-color: #a10b0b; + background: linear-gradient(180deg, #b81c1c 0%, #b80c0b 100%); + background-color: #ac0f1a; + } + + .destructive:active, + .destructive:active:focus { + border-color: #a10b0b; + background: linear-gradient(180deg, #b81c1c 0%, #b80c0b 100%); + background-color: #ac101b; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18) inset; + } + + .destructive:focus { + background: linear-gradient(180deg, #d32f2f 0%, #b71c1c 100%); + background-color: #b72229; + } + + .destructive:focus:after { + border-color: #5e0303; + } + + .destructive:disabled { + border-color: #a10b0b; + background: linear-gradient(180deg, #d32f2f 0%, #b71c1c 100%); + background-color: #e5b5b7; + box-shadow: none; + color: ${colors.white}; + fill: ${colors.white}; + opacity: 0.33; + } +` diff --git a/packages/core/src/ButtonStrip/ButtonStrip.js b/packages/core/src/ButtonStrip/ButtonStrip.js new file mode 100644 index 0000000000..11737b8508 --- /dev/null +++ b/packages/core/src/ButtonStrip/ButtonStrip.js @@ -0,0 +1,64 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { spacers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {ButtonStrip.PropTypes} props + * @returns {React.Component} + * @example import { ButtonStrip } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/buttonstrip--default|Storybook} + */ +const ButtonStrip = ({ className, children, middle, end, dataTest }) => ( +
+ {children} + + +
+) + +const alignmentPropType = propTypes.mutuallyExclusive( + ['middle', 'end'], + propTypes.bool +) + +ButtonStrip.defaultProps = { + dataTest: 'dhis2-uicore-buttonstrip', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [className] + * @prop {Array. + + + +)) diff --git a/packages/core/src/ButtonStrip/ButtonStrip.stories.js b/packages/core/src/ButtonStrip/ButtonStrip.stories.js new file mode 100644 index 0000000000..8b523a9aa2 --- /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('Components/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..aaab1cd582 --- /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('Components/Core/Card', module) + .addDecorator(Wrapper) + .add('Default', () => ) diff --git a/packages/core/src/CenteredContent/CenteredContent.js b/packages/core/src/CenteredContent/CenteredContent.js new file mode 100644 index 0000000000..3ff12d7d21 --- /dev/null +++ b/packages/core/src/CenteredContent/CenteredContent.js @@ -0,0 +1,61 @@ +import cx from 'classnames' +import React, { forwardRef } from 'react' +import propTypes from '@dhis2/prop-types' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {CenteredContent.PropTypes} props + * @returns {React.Component} + * @example import { CenteredContent } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/component-widget-centeredcontent--default|Storybook} + */ +const CenteredContent = forwardRef( + ({ className, dataTest, children, position }, ref) => ( +
+ {children} + +
+ ) +) + +CenteredContent.displayName = 'CenteredContent' + +CenteredContent.defaultProps = { + dataTest: 'dhis2-uicore-centeredcontent', + position: 'middle', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [className] + * @prop {Node} [children] + * @prop {string} [dataTest=dhis2-uicore-centeredcontent] + * @prop {string} [position=middle] One of `top`, `middle`, `bottom` + */ +CenteredContent.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + position: propTypes.oneOf(['top', 'middle', 'bottom']), +} + +export { CenteredContent } diff --git a/packages/core/src/CenteredContent/CenteredContent.stories.js b/packages/core/src/CenteredContent/CenteredContent.stories.js new file mode 100644 index 0000000000..96c9ea7ef9 --- /dev/null +++ b/packages/core/src/CenteredContent/CenteredContent.stories.js @@ -0,0 +1,26 @@ +import React from 'react' + +import { CenteredContent } from './CenteredContent.js' + +export default { + title: 'Components/Core/CenteredContent', + component: CenteredContent, +} + +export const Default = () => ( + + Center me + +) + +export const Top = () => ( + + Center me + +) + +export const Bottom = () => ( + + Center me + +) diff --git a/packages/core/src/Checkbox/Checkbox.js b/packages/core/src/Checkbox/Checkbox.js new file mode 100644 index 0000000000..54c05338d7 --- /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 { CheckboxRegular, CheckboxDense } 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..4c2e8b35c3 --- /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('Components/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..c4c0095cb4 --- /dev/null +++ b/packages/core/src/Chip/Chip.js @@ -0,0 +1,142 @@ +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, + dense, + disabled, + dragging, + overflow, + className, + children, + onRemove, + onClick, + icon, + dataTest, +}) => ( + { + if (!disabled && onClick) { + onClick({}, e) + } + }} + className={cx(className, { + selected, + dense, + 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} [dense] + * @prop {boolean} [disabled] + * @prop {boolean} [dragging] + * @prop {boolean} [overflow] + * @prop {string} [dataTest] + */ +Chip.propTypes = { + children: propTypes.string, + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + 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..778fa3c934 --- /dev/null +++ b/packages/core/src/Chip/Chip.stories.js @@ -0,0 +1,58 @@ +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('Components/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 + + )) + + .add('Dense', () => I am dense) + + .add('Dense removeable', () => ( + + Removeable and dense + + )) + +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..f8aa7a2a47 --- /dev/null +++ b/packages/core/src/Chip/Remove.js @@ -0,0 +1,59 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { css, resolve } from 'styled-jsx/css' + +import { CancelOutline } from '@dhis2/ui-icons' +import { colors } from '@dhis2/ui-constants' + +const containerStyle = css` + span { + display: flex; + justify-content: center; + align-items: center; + height: 20px; + width: 20px; + margin-right: 4px; + border-radius: 12px; + margin-left: -8px; + } + span:hover { + background: ${colors.grey400}; + } +` + +const removeIcon = resolve` + svg { + fill: ${colors.grey600}; + height: 16px; + width: 16px; + cursor: pointer; + opacity: 1; + pointer-events: all; + } +` + +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..b91999cd09 --- /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: 'Components/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..9bbe122d1c --- /dev/null +++ b/packages/core/src/ComponentCover/ComponentCover.js @@ -0,0 +1,71 @@ +import cx from 'classnames' +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { layers } from '@dhis2/ui-constants' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +const createClickHandler = onClick => event => { + // don't respond to clicks that originated in the children + if (onClick && event.target === event.currentTarget) { + onClick({}, event) + } +} + +/** + * @module + * @param {ComponentCover.PropTypes} props + * @returns {React.Component} + * @example import { ComponentCover } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/component-widget-componentcover--default|Storybook} + */ +const ComponentCover = ({ + children, + className, + dataTest, + onClick, + translucent, +}) => ( +
+ {children} + +
+) + +ComponentCover.defaultProps = { + dataTest: 'dhis2-uicore-componentcover', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [className] + * @prop {Node} [children] + * @prop {string} [dataTest=dhis2-uicore-componentcover] + * @prop {boolean} [translucent] - When true a semi-transparent background is added + * @prop {function} [onClick] + */ +ComponentCover.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + translucent: propTypes.bool, + onClick: propTypes.func, +} + +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..38906edbc9 --- /dev/null +++ b/packages/core/src/ComponentCover/ComponentCover.stories.e2e.js @@ -0,0 +1,44 @@ +import React from 'react' +import { ComponentCover } from './ComponentCover.js' + +window.onButtonClick = window.Cypress && window.Cypress.cy.stub() +window.onComponentCoverClick = window.Cypress && window.Cypress.cy.stub() + +export default { + title: 'ComponentCover', + component: ComponentCover, + decorators: [ + storyFn => ( +
+ {storyFn()} + +
+ ), + ], +} + +export const WithChildren = () => ( + +

I am a child

+
+) + +export const Blocking = () => ( + <> + + + +) + +export const WithClickHandler = () => ( + + + +) diff --git a/packages/core/src/ComponentCover/ComponentCover.stories.js b/packages/core/src/ComponentCover/ComponentCover.stories.js new file mode 100644 index 0000000000..e0fc4dcaf7 --- /dev/null +++ b/packages/core/src/ComponentCover/ComponentCover.stories.js @@ -0,0 +1,63 @@ +import React from 'react' +import { ComponentCover } from './ComponentCover.js' +import { CircularLoader, CenteredContent } from '../index.js' + +export default { + title: 'Components/Core/ComponentCover', + component: ComponentCover, + decorators: [ + storyFn => ( +
+ {storyFn()} + +
+ ), + ], +} + +export const Default = () => ( + <> + + +

Text behind the cover

+

Lorem ipsum

+ +) + +export const Translucent = () => ( + <> + + +

Text behind the cover

+

Lorem ipsum

+ +) + +export const WithClickHandler = () => ( + <> + alert('Cover was clicked')} /> + +

Text behind the cover

+

Lorem ipsum

+ +) + +export const WithCenteredContentCircularLoader = () => ( + <> + + + + + + +

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..3e335569d0 --- /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: 'Components/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..fd00b41d98 --- /dev/null +++ b/packages/core/src/DropdownButton/DropdownButton.js @@ -0,0 +1,168 @@ +import propTypes from '@dhis2/prop-types' +import React, { Component } from 'react' +import { resolve } from 'styled-jsx/css' + +import { spacers } from '@dhis2/ui-constants' +import { ArrowDown, ArrowUp } from '@dhis2/ui-icons' + +import { Button } from '../Button/Button.js' +import { Layer } from '../Layer/Layer.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 && ( + + + {component} + + + )} + + {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..4ed824f51e --- /dev/null +++ b/packages/core/src/DropdownButton/DropdownButton.stories.js @@ -0,0 +1,91 @@ +import React from 'react' +import { DropdownButton } from './DropdownButton.js' + +export default { + title: 'Components/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..c821d500d5 --- /dev/null +++ b/packages/core/src/Field/Field.js @@ -0,0 +1,103 @@ +import propTypes from '@dhis2/prop-types' +import React from 'react' + +import { sharedPropTypes, spacers } from '@dhis2/ui-constants' +import { Label } from '../Label/Label.js' +import { Help } from '../Help/Help.js' +import { Box } from '../Box/Box.js' + +/** + * @module + * @param {Field.PropTypes} props + * @returns {React.Component} + * + * @example import { Field } from '@dhis2/ui' + * + * @example import { Field } from '@dhis2/ui-core' + */ +const Field = ({ + children, + disabled, + className, + helpText, + label, + name, + validationText, + required, + dataTest, + valid, + error, + warning, +}) => ( + + {label && ( + + )} + + + {children} + + + {helpText && {helpText}} + + {validationText && ( + + {validationText} + + )} + +) + +Field.defaultProps = { + dataTest: 'dhis2-uicore-field', +} + +/** + * @typedef {Object} PropTypes + * @static + * @private + * + * @prop {Node} [children] + * @prop {string} [className] + * @prop {boolean} [disabled] + * @prop {string} [helpText] + * @prop {string} [label] + * @prop {string} [name] + * @prop {string} [validationText] + * @prop {boolean} [required] + * @prop {string} [dataTest] + * @prop {boolean} [valid] - `valid`, `warning`, and `error`, are mutually exclusive + * @prop {boolean} [warning] + * @prop {boolean} [error] + */ +Field.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + disabled: propTypes.bool, + error: sharedPropTypes.statusPropType, + helpText: propTypes.string, + label: propTypes.string, + name: propTypes.string, + required: propTypes.bool, + valid: propTypes.bool, + validationText: propTypes.string, + warning: sharedPropTypes.statusPropType, +} + +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..ebb5f3e722 --- /dev/null +++ b/packages/core/src/Field/Field.stories.js @@ -0,0 +1,27 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { Input } from '../index.js' +import { Field } from './Field.js' + +storiesOf('Components/Core/Field', module).add('Default', () => ( + <> + + { + console.log('Nothing happens') + }} + name="input" + label="An input" + /> + + + { + console.log('Nothing happens') + }} + name="input2" + label="An second input" + /> + + +)) 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..069cea5e90 --- /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('Components/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..900e39a392 --- /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('Components/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/FlyoutMenu/FlyoutMenu.js b/packages/core/src/FlyoutMenu/FlyoutMenu.js new file mode 100644 index 0000000000..baa4ce209f --- /dev/null +++ b/packages/core/src/FlyoutMenu/FlyoutMenu.js @@ -0,0 +1,97 @@ +import React, { Children, cloneElement, isValidElement, useState } from 'react' +import propTypes from '@dhis2/prop-types' +import { spacers } from '@dhis2/ui-constants' +import { resolve } from 'styled-jsx/css' + +import { Card } from '../Card/Card.js' +import { Menu } from '../Menu/Menu.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +/** + * @module + * @param {FlyoutMenu.PropTypes} + * @returns {React.Component} + * + * @example import { FlyoutMenu } from '@dhis2/ui' + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/menu.md|Design system} + * @see Live demo: {@link /demo/?path=/story/components-core-menu--default|Storybook} + */ +const FlyoutMenu = ({ + children, + className, + dataTest, + dense, + maxHeight, + maxWidth, +}) => { + const [openedSubMenu, setOpenedSubMenu] = useState(null) + const toggleSubMenu = index => { + const toggleValue = index === openedSubMenu ? null : index + setOpenedSubMenu(toggleValue) + } + + const cardCSS = resolve` + padding: ${spacers.dp4} 0; + max-height: ${maxHeight}; + overflow-y: auto; + ` + + return ( +
+ + + {Children.map(children, (child, index) => + isValidElement(child) + ? cloneElement(child, { + showSubMenu: openedSubMenu === index, + toggleSubMenu: toggleSubMenu.bind( + this, + index + ), + }) + : child + )} + + + + + {cardCSS.styles} +
+ ) +} + +FlyoutMenu.defaultProps = { + dataTest: 'dhis2-uicore-menu', + maxWidth: '380px', + maxHeight: 'auto', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {Element} [children] + * @prop {string} [className] + * @prop {string} [dataTest='dhis2-uicore-menu'] + * @prop {boolean} [dense] + * @prop {string} [maxWidth='380px'] + * @prop {string} [maxHeight='auto'] + */ +FlyoutMenu.propTypes = { + children: Menu.propTypes.children, + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + maxHeight: propTypes.string, + maxWidth: propTypes.string, +} + +export { FlyoutMenu } diff --git a/packages/core/src/FlyoutMenu/FlyoutMenu.stories.e2e.js b/packages/core/src/FlyoutMenu/FlyoutMenu.stories.e2e.js new file mode 100644 index 0000000000..6c32221baf --- /dev/null +++ b/packages/core/src/FlyoutMenu/FlyoutMenu.stories.e2e.js @@ -0,0 +1,56 @@ +import React from 'react' + +import { MenuItem } from '../MenuItem/MenuItem.js' +import { FlyoutMenu } from './FlyoutMenu.js' + +export default { + title: 'FlyoutMenu', + component: FlyoutMenu, +} + +const MenuItemSubMenuPositions = () => ( + + +
SubMenu 1
+
+
+) + +export const WithChildren = () => I am a child + +export const TogglesSubMenus = () => ( + + SubMenu 1 + SubMenu 2 + +) + +export const DefaultPosition = () => +export const FlippedPosition = () => ( +
+ + +
+) +export const ShiftIntoView = () => ( +
+ + +
+) diff --git a/packages/core/src/FlyoutMenu/FlyoutMenu.stories.js b/packages/core/src/FlyoutMenu/FlyoutMenu.stories.js new file mode 100644 index 0000000000..8a676beffc --- /dev/null +++ b/packages/core/src/FlyoutMenu/FlyoutMenu.stories.js @@ -0,0 +1,191 @@ +import React, { useState, useRef } from 'react' +import { ArrowDown } from '@dhis2/ui-icons' + +import { Layer } from '../Layer/Layer.js' +import { Popper } from '../Popper/Popper.js' +import { MenuItem } from '../MenuItem/MenuItem.js' +import { MenuDivider } from '../MenuDivider/MenuDivider.js' +import { MenuSectionHeader } from '../MenuSectionHeader/MenuSectionHeader.js' + +import { FlyoutMenu } from './FlyoutMenu.js' + +export default { + title: 'Components/Core/FlyoutMenu', + component: FlyoutMenu, +} + +export const Default = () => ( + + + + +) + +export const Dense = () => ( + + + + +) + +export const MaxHeight = () => ( + + + + + + + + + + + + +) + +export const MaxWidth = () => ( + <> + + + + +
+ + + + +
+ + + + + +) + +export const WithSubMenus = () => ( + + + + + + + + + + + + + + + + + + + + + +) + +export const WithVariousChildren = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const DropDownMenu = () => { + const ref = useRef() + const [open, setOpen] = useState(false) + const toggle = () => setOpen(!open) + + return ( + <> + + {open && ( + + + + + + )} + + ) +} + +export const WithCustomMenuItem = () => { + // You should not create custom components in the render cycle + // this is just for demo purposes + // eslint-disable-next-line react/prop-types + const PopupWindowMenuItem = ({ to, children, ...rest }) => { + const HEIGHT = 1000 + const WIDTH = 1400 + const centerY = (window.screen.height - HEIGHT) / 2 + const centerX = (window.screen.width - WIDTH) / 2 + + const onClick = () => + window.open( + to, + 'Popup', + [ + 'menubar=no', + 'location=no', + 'resizable=no', + 'scrollbars=no', + 'status=no', + `width=${WIDTH}`, + `height=${HEIGHT}`, + `top=${centerY}`, + `left=${centerX}`, + ].join() + ) + + return + } + + return ( + + + + A custom menu item (popup window) + + + ) +} 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..14641ef0a5 --- /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('Components/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/Layer/Layer.js b/packages/core/src/Layer/Layer.js new file mode 100644 index 0000000000..2bc22d2ec8 --- /dev/null +++ b/packages/core/src/Layer/Layer.js @@ -0,0 +1,117 @@ +import cx from 'classnames' +import React, { createContext, useState, useContext } from 'react' +import { createPortal } from 'react-dom' +import propTypes from '@dhis2/prop-types' +import { layers } from '@dhis2/ui-constants' + +const LayerContext = createContext({ + node: document.body, + level: 0, +}) + +const useLayerContext = () => useContext(LayerContext) + +const createClickHandler = onClick => event => { + // don't respond to clicks that originated in the children + if (onClick && event.target === event.currentTarget) { + onClick({}, event) + } +} + +/** + * @module + * @param {Layer.PropTypes} props + * @returns {React.Component} + * @example import { } from @dhis2/ui-core + * @see Live demo: {@link /demo/?path=/story/component-widget-layer--default|Storybook} + */ +const Layer = ({ + children, + className, + dataTest, + level, + onClick, + position, + translucent, +}) => { + const parentLayer = useLayerContext() + const [layerEl, setLayerEl] = useState(null) + const nextLayer = { + node: layerEl, + level: Math.max(parentLayer.level, level), + } + const portalNode = + level > parentLayer.level ? document.body : parentLayer.node + + return createPortal( +
+ {layerEl && ( + + {children} + + )} + + +
, + portalNode + ) +} + +Layer.defaultProps = { + position: 'fixed', + dataTest: 'dhis2-uicore-layer', + level: layers.applicationTop, +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {string} [className] + * @prop {Node} [children] + * @prop {string} [dataTest=dhis2-uicore-layer] + * @prop {number} [level=layers.applicationTop] + * @prop {boolean} [translucent] - When true a semi-transparent background is added + * @prop {function} [onClick] + */ +Layer.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + level: propTypes.number, + position: propTypes.oneOf(['absolute', 'fixed']), + translucent: propTypes.bool, + onClick: propTypes.func, +} + +export { Layer, useLayerContext } diff --git a/packages/core/src/Layer/Layer.stories.e2e.js b/packages/core/src/Layer/Layer.stories.e2e.js new file mode 100644 index 0000000000..2bc195d491 --- /dev/null +++ b/packages/core/src/Layer/Layer.stories.e2e.js @@ -0,0 +1,107 @@ +import React from 'react' +import { layers } from '@dhis2/ui-constants' + +import { Layer } from './Layer.js' + +window.onButtonClick = window.Cypress && window.Cypress.cy.stub() +window.onLayerClick = window.Cypress && window.Cypress.cy.stub() + +const createNamedLayerClick = name => () => { + window.onLayerClick(name) +} + +export default { title: 'Layer', component: Layer } + +export const Default = () => ( + +

I am a child

+
+) + +export const Blocking = () => ( + <> + + + +) + +export const WithClickHandler = () => ( + + + +) + +export const EqualSiblings = () => ( + <> + + + +) + +export const InequalSiblings = () => ( + <> + + + + +) + +export const NestedLowerLevels = () => ( + + + +) + +export const NestedHigherLevels = () => ( + + + +) + +export const LevelsAreRespectedWhenNesting = () => ( + <> + + + + + + + +) + +export const NestedHigherLevelEndsOnTop = () => ( + <> + + + + + +) diff --git a/packages/core/src/Layer/Layer.stories.js b/packages/core/src/Layer/Layer.stories.js new file mode 100644 index 0000000000..0871d22f44 --- /dev/null +++ b/packages/core/src/Layer/Layer.stories.js @@ -0,0 +1,48 @@ +import React from 'react' +import { Layer } from './Layer.js' +import { CircularLoader, CenteredContent } from '../index.js' + +export default { + title: 'Components/Core/Layer', + component: Layer, +} + +export const Default = () => ( + <> + + +

Text behind the layer

+

Lorem ipsum

+ +) + +export const Translucent = () => ( + <> + + +

Text behind the layer

+

Lorem ipsum

+ +) + +export const WithClickHandler = () => ( + <> + alert('layer was clicked')} /> + +

Text behind the layer

+

Lorem ipsum

+ +) + +export const WithCenteredContentCircularLoader = () => ( + <> + + + + + + +

Text behind the layer

+

Lorem ipsum

+ +) 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..9207200054 --- /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('Components/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..c84a9700b9 --- /dev/null +++ b/packages/core/src/LinearLoader/LinearLoader.stories.js @@ -0,0 +1,30 @@ +import React from 'react' +import { layers } from '@dhis2/ui-constants' + +import { LinearLoader } from './LinearLoader.js' +import { Layer, CenteredContent, ComponentCover } from '../index.js' + +export default { + title: 'Components/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..9ed4e259bb --- /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('Components/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..48ef8ccf12 --- /dev/null +++ b/packages/core/src/Menu/Menu.js @@ -0,0 +1,66 @@ +import React, { Children, cloneElement, isValidElement } from 'react' +import propTypes from '@dhis2/prop-types' + +/** + * @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/components-core-menulist--default|Storybook} + */ +const Menu = ({ children, className, dataTest, dense }) => ( +
    + {Children.map(children, (child, index) => + isValidElement(child) + ? cloneElement(child, { + dense: + typeof child.props.dense === 'boolean' + ? child.props.dense + : dense, + hideDivider: + typeof child.props.hideDivider !== 'boolean' && + index === 0 + ? true + : child.props.dense, + }) + : child + )} + + +
+) + +Menu.defaultProps = { + dataTest: 'dhis2-uicore-menulist', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest='dhis2-uicore-menulist'] + * @prop {boolean} [dense] + */ +Menu.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, +} + +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..62efab1190 --- /dev/null +++ b/packages/core/src/Menu/Menu.stories.e2e.js @@ -0,0 +1,5 @@ +import React from 'react' +import { storiesOf } from '@storybook/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..d34c9ff4d7 --- /dev/null +++ b/packages/core/src/Menu/Menu.stories.js @@ -0,0 +1,50 @@ +import React from 'react' + +import { MenuItem } from '../MenuItem/MenuItem.js' +import { Menu } from './Menu.js' + +export default { + title: 'Components/Core/Menu', + component: Menu, +} + +export const Default = () => ( + + + + +) + +export const Dense = () => ( + + + + +) + +export const SideBarMenu = () => ( +
+ +
+ Main content +
+
+) diff --git a/packages/core/src/MenuDivider/MenuDivider.js b/packages/core/src/MenuDivider/MenuDivider.js new file mode 100644 index 0000000000..d9d4dc0260 --- /dev/null +++ b/packages/core/src/MenuDivider/MenuDivider.js @@ -0,0 +1,51 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors } from '@dhis2/ui-constants' + +import { Divider } from '../Divider/Divider.js' + +/** + * @module + * @param {MenuDivider.PropTypes} + * @returns {React.Component} + * + * @example import { MenuDivider } from '@dhis2/ui + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/menu.md|Design system} + * @see Live demo: {@link /demo/?path=/story/components-core-menudivider--default|Storybook} + */ +const MenuDivider = ({ className, dataTest, dense }) => ( +
  • + + + +
  • +) + +MenuDivider.defaultProps = { + dataTest: 'dhis2-uicore-menudivider', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [className] + * @prop {string} [dataTest='dhis2-uicore-menudivider'] + * @prop {boolean} [dense] + */ +MenuDivider.propTypes = { + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, +} + +export { MenuDivider } diff --git a/packages/core/src/MenuDivider/MenuDivider.stories.js b/packages/core/src/MenuDivider/MenuDivider.stories.js new file mode 100644 index 0000000000..bf32ce01e1 --- /dev/null +++ b/packages/core/src/MenuDivider/MenuDivider.stories.js @@ -0,0 +1,14 @@ +import React from 'react' + +import { Menu } from '../Menu/Menu.js' +import { MenuDivider } from './MenuDivider.js' + +export default { + title: 'Components/Core/MenuDivider', + component: MenuDivider, + decorators: [storyFn => {storyFn()}], +} + +export const Default = () => + +export const Dense = () => diff --git a/packages/core/src/MenuItem/MenuItem.js b/packages/core/src/MenuItem/MenuItem.js new file mode 100644 index 0000000000..5f0ddf37fa --- /dev/null +++ b/packages/core/src/MenuItem/MenuItem.js @@ -0,0 +1,143 @@ +import React, { useRef } from 'react' +import { createPortal } from 'react-dom' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' +import { ChevronRight } from '@dhis2/ui-icons' + +import { Popper } from '../Popper/Popper.js' +import { FlyoutMenu } from '../FlyoutMenu/FlyoutMenu.js' +import { useLayerContext } from '../Layer/Layer.js' + +import styles from './MenuItem.styles.js' + +const createOnClickHandler = (onClick, toggleSubMenu, value) => evt => { + if (onClick || toggleSubMenu) { + evt.preventDefault() + evt.stopPropagation() + + onClick && onClick({ value }, evt) + toggleSubMenu && toggleSubMenu() + } +} +/** + * @module + * @param {MenuItem.PropTypes} + * @returns {React.Component} + * + * @example import { MenuItem } from '@dhis2/ui + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/menu.md|Design system} + * @see Live demo: {@link /demo/?path=/story/components-core-menulist--default|Storybook} + */ +const MenuItem = ({ + href, + onClick, + children, + target, + icon, + className, + destructive, + disabled, + dense, + active, + dataTest, + chevron, + value, + label, + showSubMenu, + toggleSubMenu, +}) => { + const menuItemRef = useRef() + const { node } = useLayerContext() + + return ( + <> +
  • + + {icon && {icon}} + + {label} + + {(chevron || children) && ( + + + + )} + + + +
  • + {children && + showSubMenu && + createPortal( + + {children} + , + node + )} + + ) +} + +MenuItem.defaultProps = { + dataTest: 'dhis2-uicore-menuitem', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {boolean} [active] + * @prop {boolean} [chevron] + * @prop {Node} [children] + * @prop {string} [className] + * @prop {string} [dataTest='dhis2-uicore-menuitem'] + * @prop {boolean} [dense] + * @prop {boolean} [destructive] + * @prop {boolean} [disabled] + * @prop {string} [href] + * @prop {Node} [icon] + * @prop {Node} [label] + * @prop {boolean} [showSubMenu] + * @prop {string} [target] + * @prop {function} [toggleSubMenu] + * @prop {string} [value] + * @prop {function} [onClick] - Click handler called with `value` in the payload + */ +MenuItem.propTypes = { + active: propTypes.bool, + chevron: propTypes.bool, + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + destructive: propTypes.bool, + disabled: propTypes.bool, + href: propTypes.string, + icon: propTypes.node, + label: propTypes.node, + showSubMenu: propTypes.bool, + target: propTypes.string, + toggleSubMenu: propTypes.func, + 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..06e773eca4 --- /dev/null +++ b/packages/core/src/MenuItem/MenuItem.stories.e2e.js @@ -0,0 +1,28 @@ +import React from 'react' + +import { Menu } from '../Menu/Menu.js' +import { MenuItem } from './MenuItem.js' + +window.onClick = window.Cypress && window.Cypress.cy.stub() + +export default { + title: 'MenuItem', + component: MenuItem, + decorators: [storyFn => {storyFn()}], +} + +export const WithLabel = () => + +export const WithOnClickAndValue = () => ( + +) + +export const WithHref = () => + +export const WithTarget = () => ( + +) + +export const WithIcon = () => ( + Icon} label="Menu item" /> +) diff --git a/packages/core/src/MenuItem/MenuItem.stories.js b/packages/core/src/MenuItem/MenuItem.stories.js new file mode 100644 index 0000000000..bceeb83190 --- /dev/null +++ b/packages/core/src/MenuItem/MenuItem.stories.js @@ -0,0 +1,67 @@ +import React, { useState } from 'react' +import { resolve } from 'styled-jsx/css' + +import { Menu } from '../Menu/Menu.js' +import { MenuItem } from './MenuItem.js' +import { Apps } from '@dhis2/ui-icons' + +export default { + title: 'Components/Core/MenuItem', + component: MenuItem, + decorators: [storyFn => {storyFn()}], +} + +export const Default = () => + +export const Active = () => + +export const Chevron = () => + +export const Dense = () => + +export const Destructive = () => + +export const Disabled = () => + +export const Link = () => ( + +) + +export const Icon = () => { + const { className, styles } = resolve` + fill: magenta; + ` + + return ( + <> + } label="Menu item" /> + } + label="Menu item - with custom icon fill" + /> + + {styles} + + ) +} + +export const OnClick = () => ( + { + console.log(payload.value, event.target) + }} + value="myValue" + label="Menu item" + /> +) + +export const ToggleMenuItem = () => { + const [on, setOn] = useState(false) + const toggleOn = () => setOn(!on) + const checkMarkStyle = { fontSize: '24px', lineHeight: '24px' } + const icon = on ? : + + return ( + + ) +} diff --git a/packages/core/src/MenuItem/MenuItem.styles.js b/packages/core/src/MenuItem/MenuItem.styles.js new file mode 100644 index 0000000000..80f2b2f820 --- /dev/null +++ b/packages/core/src/MenuItem/MenuItem.styles.js @@ -0,0 +1,118 @@ +import css from 'styled-jsx/css' +import { colors, spacers } from '@dhis2/ui-constants' + +export default css` + li { + display: flex; + align-items: stretch; + padding: 0; + cursor: pointer; + list-style: none; + transition: background-color 150ms ease-in-out; + background-color: ${colors.white}; + color: ${colors.grey900}; + fill: ${colors.grey900}; + font-size: 15px; + line-height: 18px; + user-select: none; + } + + li.dense { + font-size: 14px; + line-height: 16px; + } + + li:hover { + background-color: ${colors.grey200}; + } + + li:active, + li.active { + background-color: ${colors.grey400}; + } + + li.destructive { + color: ${colors.red700}; + fill: ${colors.red600}; + } + + li.destructive:hover { + background-color: ${colors.red050}; + } + + li.destructive:active, + li.destructive.active { + background-color: ${colors.red100}; + } + + li.disabled { + cursor: not-allowed; + color: ${colors.grey500}; + fill: ${colors.grey500}; + } + + li.disabled:hover { + background-color: ${colors.white}; + } + + a { + display: inline-flex; + flex-grow: 1; + align-items: center; + padding: 0 ${spacers.dp24}; + min-height: 48px; + text-decoration: none; + color: inherit; + } + + li.with-chevron a { + padding-right: ${spacers.dp8}; + } + + li.dense a { + padding: 0 ${spacers.dp12}; + min-height: 32px; + } + + li.with-chevron.dense a { + padding-right: ${spacers.dp4}; + } + + .label { + flex-grow: 1; + padding: 15px 0; + } + + li.dense .label { + padding: 8px 0; + } + + .icon { + flex-grow: 0; + margin-right: ${spacers.dp16}; + width: 24px; + height: 24px; + } + + .chevron { + flex-grow: 0; + margin-left: ${spacers.dp48}; + } + + li.dense .icon { + margin-right: ${spacers.dp8}; + width: 16px; + height: 16px; + } + + li .icon > :global(svg) { + width: 24px; + height: 24px; + } + + li.dense .icon > :global(svg), + li .chevron > :global(svg) { + width: 16px; + height: 16px; + } +` diff --git a/packages/core/src/MenuSectionHeader/MenuSectionHeader.js b/packages/core/src/MenuSectionHeader/MenuSectionHeader.js new file mode 100644 index 0000000000..4caa0c6885 --- /dev/null +++ b/packages/core/src/MenuSectionHeader/MenuSectionHeader.js @@ -0,0 +1,78 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' +import { colors, spacers } from '@dhis2/ui-constants' + +import { Divider } from '../Divider/Divider.js' + +/** + * @module + * @param {MenuSectionHeader.PropTypes} + * @returns {React.Component} + * + * @example import { MenuSectionHeader } from '@dhis2/ui + * + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/menu.md|Design system} + * @see Live demo: {@link /demo/?path=/story/components-core-menusectionheader--default|Storybook} + */ +const MenuSectionHeader = ({ + className, + dataTest, + dense, + hideDivider, + label, +}) => ( +
  • + {!hideDivider && } + +
    {label}
    + + +
  • +) + +MenuSectionHeader.defaultProps = { + dataTest: 'dhis2-uicore-menusectionheader', +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {string} [className] + * @prop {string} [dataTest='dhis2-uicore-menusectionheader'] + * @prop {boolean} [dense] + * @prop {boolean} [hideDivider] + * @prop {Node} [label] + */ +MenuSectionHeader.propTypes = { + className: propTypes.string, + dataTest: propTypes.string, + dense: propTypes.bool, + hideDivider: propTypes.bool, + label: propTypes.node, +} + +export { MenuSectionHeader } diff --git a/packages/core/src/MenuSectionHeader/MenuSectionHeader.stories.e2e.js b/packages/core/src/MenuSectionHeader/MenuSectionHeader.stories.e2e.js new file mode 100644 index 0000000000..4f5ffcaca3 --- /dev/null +++ b/packages/core/src/MenuSectionHeader/MenuSectionHeader.stories.e2e.js @@ -0,0 +1,12 @@ +import React from 'react' + +import { Menu } from '../Menu/Menu.js' +import { MenuSectionHeader } from './MenuSectionHeader.js' + +export default { + title: 'MenuSectionHeader', + component: MenuSectionHeader, + decorators: [storyFn => {storyFn()}], +} + +export const WithLabel = () => diff --git a/packages/core/src/MenuSectionHeader/MenuSectionHeader.stories.js b/packages/core/src/MenuSectionHeader/MenuSectionHeader.stories.js new file mode 100644 index 0000000000..f900f65caf --- /dev/null +++ b/packages/core/src/MenuSectionHeader/MenuSectionHeader.stories.js @@ -0,0 +1,18 @@ +import React from 'react' + +import { Menu } from '../Menu/Menu.js' +import { MenuSectionHeader } from './MenuSectionHeader.js' + +export default { + title: 'Components/Core/MenuSectionHeader', + component: MenuSectionHeader, + decorators: [storyFn => {storyFn()}], +} + +export const Default = () => + +export const Dense = () => + +export const WithoutDivider = () => ( + +) diff --git a/packages/core/src/Modal/Modal.js b/packages/core/src/Modal/Modal.js new file mode 100644 index 0000000000..157d5703fc --- /dev/null +++ b/packages/core/src/Modal/Modal.js @@ -0,0 +1,118 @@ +import cx from 'classnames' +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { layers, spacers, spacersNum } from '@dhis2/ui-constants' +import { resolve } from 'styled-jsx/css' + +import { Layer } from '../Layer/Layer.js' +import { CenteredContent } from '../CenteredContent/CenteredContent.js' +import { sharedPropTypes } from '@dhis2/ui-constants' + +import { Card } from '../Card/Card.js' +;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 + +const scrollBoxCard = resolve` + div { + display: flex; + flex-direction: column; + } +` + +/** + * @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, +}) => ( + + + + {scrollBoxCard.styles} + + + +) + +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..3ddc736ff6 --- /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('Components/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/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..b6ecd2b382 --- /dev/null +++ b/packages/core/src/MultiSelect/FilterableMenu.js @@ -0,0 +1,43 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +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: propTypes.arrayOf(propTypes.string), + 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..9c52a17cf7 --- /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 } 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: propTypes.arrayOf(propTypes.string), + 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..a6ea9c5cf3 --- /dev/null +++ b/packages/core/src/MultiSelect/Menu.js @@ -0,0 +1,93 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +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 }) => (_, e) => { + e.stopPropagation() + + // If the option is currently selected remove it from the array of selected options + if (isActive) { + const filtered = removeOption(value, selected) + const data = { selected: filtered } + + return onChange(data, e) + } + + // Otherwise, add it to selected + const data = { + selected: selected.concat([value]), + } + 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, 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: propTypes.arrayOf(propTypes.string), + 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..05606ea55d --- /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: propTypes.arrayOf(propTypes.string), + 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..26f6d0514c --- /dev/null +++ b/packages/core/src/MultiSelect/MultiSelect.stories.e2e.js @@ -0,0 +1,352 @@ +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', () => ( + + + + + + )) + .add('Default position', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('Flipped position', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('Shifted into view', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('With duplicate selected option values', () => ( + + + + + + + )) + .add('With options that can be added to the input', () => { + const [values, setValues] = React.useState([]) + return ( + <> + { + window.onChange && window.onChange({ selected }) + setValues(selected) + }} + > + + + + + + + ) + }) diff --git a/packages/core/src/MultiSelect/MultiSelect.stories.js b/packages/core/src/MultiSelect/MultiSelect.stories.js new file mode 100644 index 0000000000..c965ac9534 --- /dev/null +++ b/packages/core/src/MultiSelect/MultiSelect.stories.js @@ -0,0 +1,322 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { storiesOf } from '@storybook/react' +import { MultiSelect, 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('Components/Core/MultiSelect', module) + .add('With options', () => ( + + + + + + )) + .add('With options and onChange', () => ( + console.log(val)} + selected={['1']} + > + + + + + )) + .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', () => ( + + + + + + )) + .add('Default position', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('Flipped position', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('Shifted into view', () => ( + <> + + + + + + + + + + + + + + + + + )) diff --git a/packages/core/src/MultiSelect/SelectionList.js b/packages/core/src/MultiSelect/SelectionList.js new file mode 100644 index 0000000000..52cacfd170 --- /dev/null +++ b/packages/core/src/MultiSelect/SelectionList.js @@ -0,0 +1,60 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { Chip } from '../Chip/Chip.js' +import { removeOption, findOptionChild } from '../Select/option-helpers.js' + +const createRemoveHandler = ({ selected, onChange, value }) => (_, e) => { + const filtered = removeOption(value, selected) + const data = { selected: filtered } + + onChange(data, e) +} + +const SelectionList = ({ selected, onChange, disabled, options }) => ( + + {selected.map(value => { + const selectedOption = findOptionChild(value, options) + + if (!selectedOption) { + const message = + `There is no option with the value: "${value}". ` + + 'Make sure that all the values passed to the selected ' + + 'prop match the value of 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, + }) + + return ( + + {selectedOption.props.label} + + ) + })} + +) + +SelectionList.propTypes = { + disabled: propTypes.bool, + options: propTypes.node, + selected: propTypes.arrayOf(propTypes.string), + 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..09ce06a9d2 --- /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('Components/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/NoticeBox/NoticeBox.js b/packages/core/src/NoticeBox/NoticeBox.js new file mode 100644 index 0000000000..353a3d846b --- /dev/null +++ b/packages/core/src/NoticeBox/NoticeBox.js @@ -0,0 +1,87 @@ +import React from 'react' +import cx from 'classnames' +import { spacers, colors } from '@dhis2/ui-constants' +import propTypes from '@dhis2/prop-types' +import { NoticeBoxTitle } from './NoticeBoxTitle.js' +import { NoticeBoxIcon } from './NoticeBoxIcon.js' +import { NoticeBoxMessage } from './NoticeBoxMessage.js' + +/** + * @module + * + * @param {NoticeBox.PropTypes} props + * @returns {React.Component} + * + * @example import { NoticeBox } from '@dhis2/ui-core' + * + * @see Live demo: {@link /demo/?path=/story/component-widget-noticebox--default|Storybook} + */ +export const NoticeBox = ({ + className, + children, + dataTest, + title, + warning, + error, +}) => { + const classnames = cx(className, 'root', { warning, error }) + + return ( +
    + +
    + + + {children} + +
    + + +
    + ) +} + +NoticeBox.defaultProps = { + dataTest: 'dhis2-uicore-noticebox', +} + +/** + * @typedef {Object} PropTypes + * @static + * @prop {Node} [children] + * @prop {className} [string] + * @prop {title} [string] + * @prop {string} [dataTest] + * @prop {boolean} [warning] - `warning` and `error` are mutually exclusive boolean props + * @prop {boolean} [error] + */ +NoticeBox.propTypes = { + children: propTypes.node, + className: propTypes.string, + dataTest: propTypes.string, + error: propTypes.mutuallyExclusive(['error', 'warning'], propTypes.bool), + title: propTypes.string, + warning: propTypes.mutuallyExclusive(['error', 'warning'], propTypes.bool), +} diff --git a/packages/core/src/NoticeBox/NoticeBox.stories.e2e.js b/packages/core/src/NoticeBox/NoticeBox.stories.e2e.js new file mode 100644 index 0000000000..bd834f480e --- /dev/null +++ b/packages/core/src/NoticeBox/NoticeBox.stories.e2e.js @@ -0,0 +1,7 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { NoticeBox } from './NoticeBox.js' + +storiesOf('NoticeBox', module) + .add('With children', () => The noticebox content) + .add('With title', () => ) diff --git a/packages/core/src/NoticeBox/NoticeBox.stories.js b/packages/core/src/NoticeBox/NoticeBox.stories.js new file mode 100644 index 0000000000..c7add23231 --- /dev/null +++ b/packages/core/src/NoticeBox/NoticeBox.stories.js @@ -0,0 +1,40 @@ +import React from 'react' +import { NoticeBox } from './NoticeBox.js' + +export default { + title: 'Components/Core/NoticeBox', + component: NoticeBox, +} + +export const Default = () => ( + + Data shown in this dashboard may take a few hours to update. Scheduled + dashboard updates can be managed in the scheduler app. + +) + +export const Warning = () => ( + + No one will be able to access this program. Add some Organisation Units + to the access list. + +) + +export const Error = () => ( + + Data could be accessed from outside this instance. Update access rules + immediately. + +) + +const text = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + + 'Ut semper interdum scelerisque. Suspendisse ut velit sed' + + 'lacus pretium convallis vitae sit amet purus. Nam ut' + + 'libero rhoncus, consectetur sem a, sollicitudin lectus.' + +export const WithALongTitle = () => ( + + The title text will wrap + +) diff --git a/packages/core/src/NoticeBox/NoticeBoxIcon.js b/packages/core/src/NoticeBox/NoticeBoxIcon.js new file mode 100644 index 0000000000..05f6a7221e --- /dev/null +++ b/packages/core/src/NoticeBox/NoticeBoxIcon.js @@ -0,0 +1,46 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors, spacers } from '@dhis2/ui-constants' +import { Info, Warning, Error as ErrorIcon } from '@dhis2/ui-icons' +import css from 'styled-jsx/css' + +const getIconStyles = color => + css.resolve` + svg { + fill: ${color}; + width: 24px; + height: 24px; + margin-right: ${spacers.dp12}; + } + ` + +export const NoticeBoxIcon = ({ warning, error, dataTest }) => { + // Info is the default icon + let color = colors.blue900 + let Icon = Info + + if (warning) { + color = colors.yellow700 + Icon = Warning + } + + if (error) { + color = colors.red700 + Icon = ErrorIcon + } + + const { className, styles } = getIconStyles(color) + + return ( +
    + + {styles} +
    + ) +} + +NoticeBoxIcon.propTypes = { + dataTest: propTypes.string.isRequired, + error: propTypes.mutuallyExclusive(['error', 'warning'], propTypes.bool), + warning: propTypes.mutuallyExclusive(['error', 'warning'], propTypes.bool), +} diff --git a/packages/core/src/NoticeBox/NoticeBoxMessage.js b/packages/core/src/NoticeBox/NoticeBoxMessage.js new file mode 100644 index 0000000000..c058396211 --- /dev/null +++ b/packages/core/src/NoticeBox/NoticeBoxMessage.js @@ -0,0 +1,28 @@ +import React from 'react' +import { colors } from '@dhis2/ui-constants' +import propTypes from '@dhis2/prop-types' + +export const NoticeBoxMessage = ({ children, dataTest }) => { + if (!children) { + return null + } + + return ( +
    + {children} + + +
    + ) +} + +NoticeBoxMessage.propTypes = { + dataTest: propTypes.string.isRequired, + children: propTypes.node, +} diff --git a/packages/core/src/NoticeBox/NoticeBoxTitle.js b/packages/core/src/NoticeBox/NoticeBoxTitle.js new file mode 100644 index 0000000000..c151540794 --- /dev/null +++ b/packages/core/src/NoticeBox/NoticeBoxTitle.js @@ -0,0 +1,29 @@ +import React from 'react' +import { colors, spacers } from '@dhis2/ui-constants' +import propTypes from '@dhis2/prop-types' + +export const NoticeBoxTitle = ({ title, dataTest }) => { + if (!title) { + return null + } + + return ( +
    + {title} + +
    + ) +} + +NoticeBoxTitle.propTypes = { + dataTest: propTypes.string.isRequired, + title: propTypes.string, +} diff --git a/packages/core/src/NoticeBox/__tests__/NoticeBoxIcon.test.js b/packages/core/src/NoticeBox/__tests__/NoticeBoxIcon.test.js new file mode 100644 index 0000000000..f5d0480630 --- /dev/null +++ b/packages/core/src/NoticeBox/__tests__/NoticeBoxIcon.test.js @@ -0,0 +1,54 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { NoticeBoxIcon } from '../NoticeBoxIcon.js' + +describe('NoticeBoxIcon', () => { + it('should render info icon by default', () => { + const wrapper = shallow() + + expect(wrapper.find('Warning')).toHaveLength(0) + expect(wrapper.find('Error')).toHaveLength(0) + expect(wrapper.find('Info')).toHaveLength(1) + }) + + it('should log errors when both warning and error flag are set', () => { + const spy = jest + .spyOn(global.console, 'error') + .mockImplementation(() => {}) + shallow() + + expect(spy.mock.calls[0][0]).toMatchSnapshot() + expect(spy.mock.calls[1][0]).toMatchSnapshot() + + spy.mockRestore() + }) + + it('should render error icon when both warning and error flag are set', () => { + const spy = jest + .spyOn(global.console, 'error') + .mockImplementation(() => {}) + const wrapper = shallow() + + expect(wrapper.find('Warning')).toHaveLength(0) + expect(wrapper.find('Info')).toHaveLength(0) + expect(wrapper.find('Error')).toHaveLength(1) + + spy.mockRestore() + }) + + it('should render error icon when only error flag is set', () => { + const wrapper = shallow() + + expect(wrapper.find('Warning')).toHaveLength(0) + expect(wrapper.find('Info')).toHaveLength(0) + expect(wrapper.find('Error')).toHaveLength(1) + }) + + it('should render warning icon when only warning flag is set', () => { + const wrapper = shallow() + + expect(wrapper.find('Info')).toHaveLength(0) + expect(wrapper.find('Error')).toHaveLength(0) + expect(wrapper.find('Warning')).toHaveLength(1) + }) +}) diff --git a/packages/core/src/NoticeBox/__tests__/NoticeBoxMessage.test.js b/packages/core/src/NoticeBox/__tests__/NoticeBoxMessage.test.js new file mode 100644 index 0000000000..c17a22afa7 --- /dev/null +++ b/packages/core/src/NoticeBox/__tests__/NoticeBoxMessage.test.js @@ -0,0 +1,21 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { NoticeBoxMessage } from '../NoticeBoxMessage.js' + +describe('NoticeBoxMessage', () => { + it('should return null when there are no children', () => { + const props = { + dataTest: 'test', + } + + expect(NoticeBoxMessage(props)).toBe(null) + }) + + it('should render children', () => { + const wrapper = shallow( + children + ) + + expect(wrapper.text()).toEqual(expect.stringContaining('children')) + }) +}) diff --git a/packages/core/src/NoticeBox/__tests__/NoticeBoxTitle.test.js b/packages/core/src/NoticeBox/__tests__/NoticeBoxTitle.test.js new file mode 100644 index 0000000000..50eb702456 --- /dev/null +++ b/packages/core/src/NoticeBox/__tests__/NoticeBoxTitle.test.js @@ -0,0 +1,21 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { NoticeBoxTitle } from '../NoticeBoxTitle.js' + +describe('NoticeBoxTitle', () => { + it('should return null when there is no title', () => { + const props = { + dataTest: 'test', + } + + expect(NoticeBoxTitle(props)).toBe(null) + }) + + it('should render title', () => { + const wrapper = shallow( + + ) + + expect(wrapper.text()).toEqual(expect.stringContaining('title')) + }) +}) diff --git a/packages/core/src/NoticeBox/__tests__/__snapshots__/NoticeBoxIcon.test.js.snap b/packages/core/src/NoticeBox/__tests__/__snapshots__/NoticeBoxIcon.test.js.snap new file mode 100644 index 0000000000..1a62b61d3f --- /dev/null +++ b/packages/core/src/NoticeBox/__tests__/__snapshots__/NoticeBoxIcon.test.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NoticeBoxIcon should log errors when both warning and error flag are set 1`] = ` +"Warning: Failed prop type: Invalid prop \`error\` supplied to \`NoticeBoxIcon\`, Property 'error' is mutually exclusive with 'warning', but both have a thruthy value. + in NoticeBoxIcon" +`; + +exports[`NoticeBoxIcon should log errors when both warning and error flag are set 2`] = ` +"Warning: Failed prop type: Invalid prop \`warning\` supplied to \`NoticeBoxIcon\`, Property 'warning' is mutually exclusive with 'error', but both have a thruthy value. + in NoticeBoxIcon" +`; diff --git a/packages/core/src/Popover/Arrow.js b/packages/core/src/Popover/Arrow.js new file mode 100644 index 0000000000..f2a16d5a37 --- /dev/null +++ b/packages/core/src/Popover/Arrow.js @@ -0,0 +1,87 @@ +import React, { forwardRef } from 'react' +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import { colors } from '@dhis2/ui-constants' + +import { getArrowPosition } from './getArrowPosition.js' + +const ARROW_SIZE = 8 + +const Arrow = forwardRef(({ hidden, popperPlacement, styles }, ref) => ( + +)) +Arrow.displayName = 'Arrow' + +Arrow.propTypes = { + hidden: propTypes.bool, + popperPlacement: propTypes.string, + styles: propTypes.shape({ + left: propTypes.string, + position: propTypes.string, + top: propTypes.string, + transform: 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..48a2553705 --- /dev/null +++ b/packages/core/src/Popover/Popover.js @@ -0,0 +1,121 @@ +import React, { useState, useMemo } from 'react' +import propTypes from 'prop-types' +import { usePopper } from 'react-popper' +import { colors, elevations, sharedPropTypes } from '@dhis2/ui-constants' +import { Layer } from '../Layer/Layer.js' +import { Arrow } from './Arrow.js' +import { combineModifiers } from './modifiers.js' +import { getReferenceElement } from '../Popper/getReferenceElement.js' + +/** + * @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/components-core-popover--default|Storybook} + */ + +const Popover = ({ + children, + reference, + arrow, + className, + dataTest, + elevation, + maxWidth, + observePopperResize, + observeReferenceResize, + placement, + onClickOutside, +}) => { + const referenceElement = getReferenceElement(reference) + const [popperElement, setPopperElement] = useState(null) + const [arrowElement, setArrowElement] = useState(null) + const modifiers = useMemo( + () => + combineModifiers(arrow, arrowElement, { + observePopperResize, + observeReferenceResize, + }), + [arrow, arrowElement, observePopperResize, observeReferenceResize] + ) + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement, + modifiers, + }) + + return ( + +
    + {children} + {arrow && ( +
    +
    + ) +} + +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} [onClickOutside] + */ +Popover.propTypes = { + children: propTypes.node.isRequired, + arrow: propTypes.bool, + className: propTypes.string, + dataTest: propTypes.string, + elevation: propTypes.string, + maxWidth: propTypes.number, + observePopperResize: propTypes.bool, + observeReferenceResize: propTypes.bool, + placement: sharedPropTypes.popperPlacementPropType, + reference: sharedPropTypes.popperReferencePropType, + onClickOutside: 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..ead61bc165 --- /dev/null +++ b/packages/core/src/Popover/Popover.stories.e2e.js @@ -0,0 +1,75 @@ +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, +} + +window.onClickOutside = window.Cypress && window.Cypress.cy.stub() + +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 = () => ( + +) +export const WithOnClickOutside = () => ( + +) diff --git a/packages/core/src/Popover/Popover.stories.js b/packages/core/src/Popover/Popover.stories.js new file mode 100644 index 0000000000..74ff30edb1 --- /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: 'Components/Core/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 clicked....') + }} + > +
    + 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/getArrowPosition.js b/packages/core/src/Popover/getArrowPosition.js new file mode 100644 index 0000000000..d5e4cf2ebf --- /dev/null +++ b/packages/core/src/Popover/getArrowPosition.js @@ -0,0 +1,19 @@ +const getArrowPosition = popperPlacement => { + const direction = + typeof popperPlacement === 'string' ? popperPlacement.split('-')[0] : '' + + switch (direction) { + case 'top': + return 'bottom' + case 'right': + return 'left' + case 'bottom': + return 'top' + case 'left': + return 'right' + default: + return '' + } +} + +export { getArrowPosition } diff --git a/packages/core/src/Popover/modifiers.js b/packages/core/src/Popover/modifiers.js new file mode 100644 index 0000000000..01ab28759d --- /dev/null +++ b/packages/core/src/Popover/modifiers.js @@ -0,0 +1,62 @@ +import { getBaseModifiers } from '../Popper/modifiers.js' +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) +} + +const hideArrowWhenDisplaced = ({ 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 + + if (typeof state.attributes.arrow !== 'object') { + state.attributes.arrow = {} + } + + state.attributes.arrow['data-arrow-hidden'] = shouldHideArrow + + return state +} + +export const combineModifiers = (arrow, arrowElement, resizeObservers) => { + const baseModifiers = getBaseModifiers(resizeObservers) + + if (!arrow) { + return baseModifiers + } + + return [ + ...baseModifiers, + { + name: 'offset', + options: { + offset: [0, ARROW_SIZE], + }, + }, + { + name: 'arrow', + options: { + padding: computeArrowPadding(), + element: arrowElement, + }, + }, + { + name: 'hideArrowWhenDisplaced', + enabled: true, + phase: 'main', + fn: hideArrowWhenDisplaced, + requires: ['preventOverflow'], + }, + ] +} diff --git a/packages/core/src/Popper/Popper.js b/packages/core/src/Popper/Popper.js new file mode 100644 index 0000000000..75a77eb153 --- /dev/null +++ b/packages/core/src/Popper/Popper.js @@ -0,0 +1,112 @@ +import React, { useState, useMemo } from 'react' +import propTypes from '@dhis2/prop-types' +import { usePopper } from 'react-popper' +import { sharedPropTypes } from '@dhis2/ui-constants' + +import { deduplicateModifiers } from './modifiers.js' +import { getReferenceElement } from './getReferenceElement.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/components-core-popper--top|Storybook} + * @see Popper js: {@link https://popper.js.org/docs/v2/|Documentation} + */ + +const Popper = ({ + children, + className, + dataTest, + modifiers, + observePopperResize, + observeReferenceResize, + onFirstUpdate, + placement, + reference, + strategy, +}) => { + const referenceElement = getReferenceElement(reference) + const [popperElement, setPopperElement] = useState(null) + + const deduplicatedModifiers = useMemo( + () => + deduplicateModifiers(modifiers, { + observePopperResize, + observeReferenceResize, + }), + [modifiers, observePopperResize, observeReferenceResize] + ) + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + strategy, + onFirstUpdate, + placement, + modifiers: deduplicatedModifiers, + }) + + return ( +
    + {children} +
    + ) +} + +Popper.defaultProps = { + dataTest: 'dhis2-uicore-popper', + modifiers: [], + placement: 'auto', +} + +/** + * @typedef Modifier + * @type {Object} + * @property {string} name + * @property {Object} options + */ + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {Node} children + * @prop {React.Ref|Element|VirtualElement} reference A React ref, DOM node, or {@link https://popper.js.org/docs/v2/virtual-elements/|popper.js virtual element} for the Popper to position itself 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 {Boolean} observePopperResize Makes the popper update position when the popper content changes size + * @prop {Boolean} observeReferenceResize Makes the popper update position when the reference element changes size + * @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, + className: propTypes.string, + dataTest: propTypes.string, + modifiers: propTypes.arrayOf( + propTypes.shape({ + name: propTypes.string, + options: propTypes.object, + }) + ), + observePopperResize: propTypes.bool, + observeReferenceResize: propTypes.bool, + placement: sharedPropTypes.popperPlacementPropType, + reference: sharedPropTypes.elementRefPropType, + 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..e048254925 --- /dev/null +++ b/packages/core/src/Popper/Popper.stories.js @@ -0,0 +1,163 @@ +import React, { Component, createRef } from 'react' +import propTypes from '@dhis2/prop-types' + +import { Popper } from './Popper.js' + +export default { + title: 'Components/Core/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
    +
    +) +export const ElementRef = () => { + const anchor = document.createElement('div') + document.body.appendChild(anchor) + + return ( + +
    Left end
    + +
    + ) +} +export const VirtualElementRef = () => { + const virtualElement = { + getBoundingClientRect: () => ({ + width: 0, + height: 0, + top: 100, + right: 0, + bottom: 0, + left: 200, + x: 200, + y: 100, + }), + } + + return ( + +
    Left end
    + +
    + ) +} diff --git a/packages/core/src/Popper/getReferenceElement.js b/packages/core/src/Popper/getReferenceElement.js new file mode 100644 index 0000000000..8abd2e57cb --- /dev/null +++ b/packages/core/src/Popper/getReferenceElement.js @@ -0,0 +1,18 @@ +const getReferenceElement = reference => { + // Elements or virtualElements + if ( + reference instanceof Element || + (reference && 'getBoundingClientRect' in reference) + ) { + return reference + } + + // react refs + if (reference && 'current' in reference) { + return reference.current + } + + return null +} + +export { getReferenceElement } diff --git a/packages/core/src/Popper/modifiers.js b/packages/core/src/Popper/modifiers.js new file mode 100644 index 0000000000..f5029f1003 --- /dev/null +++ b/packages/core/src/Popper/modifiers.js @@ -0,0 +1,86 @@ +import ResizeObserver from 'resize-observer-polyfill' + +const attachResizeObservers = ({ + state: { elements }, + options, + instance: { update }, +}) => { + const observers = Object.keys(options).reduce((acc, elementKey) => { + if (options[elementKey]) { + const observer = new ResizeObserver(update) + observer.observe(elements[elementKey]) + acc.push(observer) + } + return acc + }, []) + + return () => { + observers.forEach(observer => { + observer.disconnect() + }) + } +} + +export const getBaseModifiers = ({ + observePopperResize, + observeReferenceResize, +}) => [ + { + name: 'preventOverflow', + options: { + altAxis: true, + rootBoundary: 'document', + boundary: document.body, + }, + }, + { + name: 'flip', + options: { + rootBoundary: 'document', + boundary: document.body, + }, + }, + { + name: 'resizeObserver', + enabled: true, + phase: 'write', + fn: () => {}, + effect: attachResizeObservers, + options: { + popper: observePopperResize, + reference: observeReferenceResize, + }, + }, +] + +export const deduplicateModifiers = (modifiers, resizeObservers) => { + // 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 getBaseModifiers(resizeObservers) + .filter(({ name }) => !modifiers.some(m => m.name === name)) + .concat(modifiers) +} + +export const resizeObserver = { + name: 'resizeObserver', + enabled: true, + phase: 'write', + fn: () => {}, + effect: ({ state: { elements }, options, instance: { update } }) => { + const observers = Object.keys(options).reduce((acc, elementKey) => { + if (options[elementKey]) { + const observer = new ResizeObserver(update) + observer.observe(elements[elementKey]) + acc.push(observer) + } + return acc + }, []) + + return () => { + observers.forEach(observer => { + observer.disconnect() + }) + } + }, +} diff --git a/packages/core/src/Radio/Radio.js b/packages/core/src/Radio/Radio.js new file mode 100644 index 0000000000..5dee88e2d9 --- /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 { RadioRegular, RadioDense } 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..a4dcd1dfbb --- /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('Components/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/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..aecf7dbe9d --- /dev/null +++ b/packages/core/src/Select/FilterableMenu.js @@ -0,0 +1,106 @@ +import React, { Component } from 'react' +import propTypes from '@dhis2/prop-types' +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([ + propTypes.string, + propTypes.arrayOf(propTypes.string), + ]).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..d7243a7ead --- /dev/null +++ b/packages/core/src/Select/MenuWrapper.js @@ -0,0 +1,57 @@ +import React from 'react' +import { resolve } from 'styled-jsx/css' + +import propTypes from '@dhis2/prop-types' + +import { Card, Layer, Popper } from '../index.js' + +const MenuWrapper = ({ + children, + dataTest, + maxHeight, + menuWidth, + onClick, + selectRef, +}) => { + const { styles, className: cardClassName } = resolve` + height: auto; + max-height: ${maxHeight}; + overflow: auto; + ` + return ( + + +
    + {children} + + {styles} + + +
    +
    +
    + ) +} + +MenuWrapper.defaultProps = { + maxHeight: '280px', +} + +MenuWrapper.propTypes = { + dataTest: propTypes.string.isRequired, + menuWidth: propTypes.string.isRequired, + selectRef: propTypes.object.isRequired, + children: propTypes.node, + maxHeight: propTypes.string, + 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..031ae8e884 --- /dev/null +++ b/packages/core/src/Select/Select.js @@ -0,0 +1,224 @@ +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' +import { debounce } from './debounce' + +// 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, + menuWidth: 'auto', + } + + selectRef = React.createRef() + inputRef = React.createRef() + + componentDidMount() { + if (this.props.initialFocus) { + this.inputRef.current.focus() + } + + this.setMenuWidth() + window.addEventListener('resize', this.setMenuWidth) + } + + componentWillUnmount() { + window.removeEventListener('resize', this.setMenuWidth) + } + + /** + * We're debouncing this so it doesn't fire continually during a resize. + * + * Additionally we should use requestPostAnimationFrame to not trigger a forced + * layout, but that's just a proposal, and the added complexity of solving this + * in another manner does not seem worth it, considering the minor perf penalty. + * + * See: https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web + */ + setMenuWidth = debounce(() => { + const inputWidth = `${this.inputRef.current.offsetWidth}px` + + if (this.state.menuWidth !== inputWidth) { + this.setState({ + menuWidth: inputWidth, + }) + } + }, 50) + + handleFocusInput = () => { + this.inputRef.current.focus() + } + + onFocus = e => { + const { onFocus, disabled, selected } = this.props + + if (disabled || !onFocus) { + return + } + + onFocus({ selected }, e) + } + + handleOpen = () => { + this.setState({ open: true }) + } + + handleClose = () => { + this.setState({ open: false }) + } + + 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, menuWidth } = this.state + const { + children, + className, + selected, + onChange, + tabIndex, + maxHeight, + error, + warning, + valid, + disabled, + dense, + dataTest, + } = this.props + + // Create the input + const inputProps = { + selected, + onChange, + options: children, + disabled, + } + const input = React.cloneElement(this.props.input, inputProps) + + // Create the menu + const menuProps = { + selected, + onChange, + 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([ + propTypes.string, + propTypes.arrayOf(propTypes.string), + ]).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/debounce/__tests__/index.test.js b/packages/core/src/Select/debounce/__tests__/index.test.js new file mode 100644 index 0000000000..037d6b630d --- /dev/null +++ b/packages/core/src/Select/debounce/__tests__/index.test.js @@ -0,0 +1,35 @@ +import { debounce } from '../index.js' + +beforeEach(() => { + jest.useFakeTimers() +}) + +describe('debounce', () => { + it('should call the debounced function once after the timeout without immediate', () => { + const spy = jest.fn() + const debounced = debounce(spy, 100) + + debounced() + debounced() + + expect(spy).not.toHaveBeenCalled() + + jest.runAllTimers() + + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should call the debounced function once immediately if immediate is set', () => { + const spy = jest.fn() + const debounced = debounce(spy, 100, true) + + debounced() + debounced() + + expect(spy).toHaveBeenCalledTimes(1) + + jest.runAllTimers() + + expect(spy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/core/src/Select/debounce/index.js b/packages/core/src/Select/debounce/index.js new file mode 100644 index 0000000000..68247ab227 --- /dev/null +++ b/packages/core/src/Select/debounce/index.js @@ -0,0 +1,30 @@ +/** + * Returns a function, that, as long as it continues to be invoked, will not be triggered. The + * function will be called after it stops being called for N milliseconds. If `immediate` is + * passed, trigger the function on the leading edge, instead of the trailing. + */ + +export const debounce = (func, wait, immediate) => { + let timeout + + return (...args) => { + const context = this + + const later = () => { + timeout = null + + if (!immediate) { + func.apply(context, args) + } + } + + const callNow = immediate && !timeout + + clearTimeout(timeout) + timeout = setTimeout(later, wait) + + if (callNow) { + func.apply(context, args) + } + } +} diff --git a/packages/core/src/Select/option-helpers.js b/packages/core/src/Select/option-helpers.js new file mode 100644 index 0000000000..ad8fcc0987 --- /dev/null +++ b/packages/core/src/Select/option-helpers.js @@ -0,0 +1,34 @@ +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 = (value, optionChildren) => + React.Children.toArray(optionChildren).find(currentOption => { + if (!currentOption.props) { + return false + } + + return value === currentOption.props.value + }) + +// Find an option in an array of option objects +export const findOption = (value, optionArray) => + optionArray.find(optionValue => value === optionValue) + +// Remove a specific option from an array of options +export const removeOption = (value, optionArray) => + optionArray.filter(optionValue => { + return optionValue !== value + }) diff --git a/packages/core/src/SingleSelect/FilterableMenu.js b/packages/core/src/SingleSelect/FilterableMenu.js new file mode 100644 index 0000000000..9b336485d7 --- /dev/null +++ b/packages/core/src/SingleSelect/FilterableMenu.js @@ -0,0 +1,43 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +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: propTypes.string, + 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..3bacb67e58 --- /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 } 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 = selected && typeof selected === 'string' + 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: propTypes.string, + 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..4c9198242e --- /dev/null +++ b/packages/core/src/SingleSelect/Menu.js @@ -0,0 +1,80 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +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, disabled: isDisabled } = child.props + + // Active means the option is currently selected + const isActive = value === selected + const onClick = (_, e) => { + const data = { selected: value } + 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: propTypes.string, + 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..9369bd5982 --- /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 } 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 = + `There is no option with the value: "${selected}". ` + + 'Make sure that the value passed to the selected ' + + 'prop matches the value of 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: propTypes.string, +} + +export { Selection } diff --git a/packages/core/src/SingleSelect/SingleSelect.js b/packages/core/src/SingleSelect/SingleSelect.js new file mode 100644 index 0000000000..2c0bae0ae7 --- /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 {String} [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: propTypes.string, + 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..a476351d22 --- /dev/null +++ b/packages/core/src/SingleSelect/SingleSelect.stories.e2e.js @@ -0,0 +1,324 @@ +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', () => ( + + + + + + )) + .add('Default position', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('Flipped position', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('Shifted into view', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('With duplicate selected option values', () => ( + + + + + + + )) diff --git a/packages/core/src/SingleSelect/SingleSelect.stories.js b/packages/core/src/SingleSelect/SingleSelect.stories.js new file mode 100644 index 0000000000..c956b3e8ad --- /dev/null +++ b/packages/core/src/SingleSelect/SingleSelect.stories.js @@ -0,0 +1,317 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' + +import propTypes from '@dhis2/prop-types' + +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('Components/Core/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', () => ( + + + + + + )) + .add('Default position', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('Flipped position', () => ( + <> + + + + + + + + + + + + + + + + + )) + .add('Shifted into view', () => ( + <> + + + + + + + + + + + + + + + + + )) 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..0eb6b57a7a --- /dev/null +++ b/packages/core/src/SplitButton/SplitButton.js @@ -0,0 +1,199 @@ +import cx from 'classnames' +import propTypes from '@dhis2/prop-types' +import React, { Component } from 'react' +import css from 'styled-jsx/css' + +import { ArrowDown, ArrowUp } from '@dhis2/ui-icons' +import { spacers, sharedPropTypes } from '@dhis2/ui-constants' + +import { Layer } from '../Layer/Layer.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 && ( + + + {component} + + + )} + + {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..38b361603b --- /dev/null +++ b/packages/core/src/SplitButton/SplitButton.stories.js @@ -0,0 +1,81 @@ +import React from 'react' +import { SplitButton } from './SplitButton.js' + +export default { + title: 'Components/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..ba85fe8ecf --- /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('Components/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..1cc873bf3a --- /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 { SwitchRegular } 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..84ba6946fa --- /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('Components/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..231a0a040d --- /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('Components/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..dd383a153a --- /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('Components/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('Components/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..29adb0a698 --- /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: 'Components/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 ( +
    +