1
0
mirror of https://gitlab.nic.cz/turris/reforis/foris-js.git synced 2025-04-20 08:16:38 +02:00

Compare commits

..

No commits in common. "dev" and "v5.5.0" have entirely different histories.
dev ... v5.5.0

130 changed files with 18955 additions and 22454 deletions

View File

@ -1,3 +1,8 @@
module.exports = {
extends: "eslint-config-reforis",
extends: ["eslint-config-reforis", "prettier"],
plugins: ["prettier"],
rules: {
"prettier/prettier": ["error"],
"import/prefer-default-export": "off",
},
};

1
.gitignore vendored
View File

@ -51,3 +51,4 @@ coverage.xml
dist/
foris-*.tgz
styleguide/
testUtils

View File

@ -1,4 +1,4 @@
image: registry.nic.cz/turris/reforis/reforis/reforis-image
image: node:16-alpine
stages:
- test
@ -6,7 +6,7 @@ stages:
- publish
before_script:
- apt-get update && apt-get install -y make
- apk add make
- npm install
test:

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"singleQuote": false,
"printWidth": 80,
"proseWrap": "always",
"tabWidth": 4,
"useTabs": false,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"semi": true
}

View File

@ -1,547 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [6.7.1] - 2025-04-04
### Added
- Added & updated Weblate translations
## [6.7.0] - 2025-03-11
### Added
- Added encryption property to guest WiFi settings in tests
- Added global fuzzy search and columns visibility to RichTable
### Changed
- Made thead of RichTable lighter
- Updated dependencies in package.json to latest versions
- Enhanced ActionButtonWithModal to support dynamic methods
- NPM audit fix
## [6.6.2] - 2025-02-20
### Changed
- Enhanced SubmitButton component to accept a custom label prop
- Refactored RichTable component to remove forwardRef and simplify data handling
## [6.6.1] - 2025-02-17
### Changed
- Refactored RichTable component to use forwardRef
## [6.6.0] - 2025-02-07
### Added
- Added & updated Weblate translations
- Added Wi-Fi and LAN settings URLs to ForisURLs
- Added Wi-Fi modes VHT/HE 80+80
- Added encryption selection to WiFiGuestForm
- Added optional close button to ModalHeader component
### Changed
- Updated Wi-Fi API
- Enhanced NumberInput component with keyboard & touch accessibility
- Refactored pagination condition in RichTable component
## [6.5.0] - 2024-11-13
### Added
- Added & updated Weblate translations
- Added RichTable component with pagination and sorting
- Added @tanstack/react-table v8.20.5 to dependencies
### Changed
- Updated documentation
- Replaced RebootButton with ActionButtonWithModal component
- Fixed import path for CustomizationContextMock in customTestRender.js
## [6.4.0] - 2024-10-02
### Changed
- Refactored Alert component to include dismiss animation and timeout
- Refactored ThreeDotsMenu component to include additional props
## [6.3.0] - 2024-09-27
### Added
- Added ThreeDotsMenu component
### Changed
- Refactored EmailInput description
- Refactored RadioSet & ignore Radio component
- Refactored npm package badge in introduction.md
- NPM audit fix
## [6.2.1] - 2024-09-25
### Added
- Added & updated Weblate translations
### Changed
- Refactored CopyInput component
- Refactored ForisURLs to include new URLs for Overview page
## [6.2.0] - 2024-09-20
### Added
- Added useFocusTrap hook
- Added extendSession endpoint
### Changed
- Refactored Spinner.css to use CSS variable for color
- Refactored Modal component to use useFocusTrap hook
- Refactored Alert component to use useFocusTrap hook
## [6.1.1] - 2024-08-30
### Added
- Added & updated Weblate translations
### Changed
- Updated icon color classes to use "text-secondary" instead of "text-dark"
- Updated Wi-Fi QRCodeModal component to use new styles & added close button
- Refactored WiFiGuestForm component to get rid of obsolete div element
- NPM audit fix
## [6.1.0] - 2024-08-23
### Added
- Added & updated Weblate translations
### Changed
- Migrated to Font Awesome v6
- NPM audit fix
## [6.0.3] - 2024-07-26
### Changed
- Updated WiFiQRCode component
## [6.0.2] - 2024-06-28
### Added
- Added className prop to CheckBox and Radio components
## [6.0.1] - 2024-06-26
### Added
- Added className prop to Switch component
### Changed
- Updated dependencies in package.json
- NPM audit fix
## [6.0.0] - 2024-06-11
### Added
- Added CHANGELOG.md
- Added JS_DIR variable to Makefile
- Added support for shared reForis ESLint configuration
### Changed
- Updated dependencies in package.json
- Updated Spinner.css styles for better positioning and responsiveness
- Migrated to Bootstrap 5
- NPM audit fix
- Other small improvements
## [5.6.1] - 2024-01-19
- Added & updated Weblate translations
- Fixed loading state & button's layout
- Updated bootstrap library to version 4.6.2
- Used custom reforis-image in GitLab CI/CD
- NPM audit fix
## [5.6.0] - 2022-12-29
- Add & update Weblate translations
- Add CustomizationContext and custom hook
- Update caniuse-lite
- Remove testUtils from .gitignore
- Make ieee80211w_disabled as optional in WiFiForm
- Move contexts in a context folder
- NPM audit fix
## [5.5.0] - 2022-12-02
- Add & update translations
- Add a switch to disable Management Frame Protection (802.11w)
- Improved Foris JS documentation
- NPM audit fix
## [5.4.1] - 2022-06-03
- Add Weblate translations
- Update PropType peer dependency
- NPM audit fix
## [5.4.0] - 2022-05-20
- Add & update translations
- Add CopyInput bootstrap component
- Update WiFiForm labels and description for wifi ax
- Make WS path in lighttpd mode configurable
- Fix Wi-Fi password helptext string
- NPM audit fix
## [5.3.0] - 2022-02-21
- Added & update translations
- Added rest of the props to DownloadButton component
- Added hostname validation
- Added wifi 802.11ax HE modes
- Set best Wi-Fi HT mode depending on the checked frequency
- Improved domain name RegEx pattern
- Removed customOrder prop in Select component
- Fixed Wi-Fi translation strings
- Fixed autocomplete attribute in PasswordInput
- Fixed WiFi password max length check
- Fixed documentation build
- Fixed access token in publish script
- Refined & restructure Makefile
- Updated GitLab CI image to Node.js v16
- NPM update (several dependencies)
- NPM audit fix
## [5.2.0] - 2021-12-15
- Remove login page
- NPM audit fix
## [5.1.16] - 2021-11-18
- Revert bad NPM audit fix
- NPM audit fix
## [5.1.15] - 2021-11-03
- Add WPA3 option
- Add custom order ability of Select options
- NPM audit fix
## [5.1.14] - 2021-07-30
- Add & update translations
- Fix infinity redirect loop when WS error occurs
- NPM audit fix
## [5.1.13] - 2021-06-30
- Add sentinelAgreement endpoint to forisUrls
- NPM audit fix
## [5.1.12] - 2021-05-14
- Add & update translations
- Add & fix obsolete links
- Expend library with the ResetWifiSettings function
- Fix switching Wi-Fi modes depending on bands in WiFiForm
- Fix translation sources in WiFiForm
- NPM audit fix
- Other small improvements
## [5.1.11] - 2021-01-04
- Remove duplicated file for Norwegian language
- Fix translations inconsistency
## [5.1.10] - 2021-12-29
- Add and update translations
## [5.1.9] - 2021-12-20
- Increase bottom margin of formFieldsSize
- Change formFieldsSize of ResetWiFiSettings card
- Fix trailing space in Modal classes
## [5.1.8] - 2020-12-19
- Add isPluginInstalled function
## [5.1.7] - 2020-11-27
## [5.1.6] - 2020-11-25
- NPM audit fix
- Add displayCard function to utils
- Add optional sizes to Modal
- Add information about optional sizes to docs
- Remove redundant merge.py
## [5.1.5] - 2020-09-25
- Fix DateTime import
- Fix extra empty space in Switch's classes
## [5.1.4] - 2020-09-25
- Add inline option to Wi-Fi's RadioSet
- Fix Alert's dismissible class condition
- Add closing bootstrap modal using ESC
- Change reboot modal's heading to "Warning!"
## [5.1.3] - 2020-09-11
- Add SSID validation for 32 bytes length
- Add helpText for SSID input
## [5.1.2] - 2020-09-08
- Fix infinity loop caused by WebSockets
- Resolve small issues
## [5.1.1] - 2020-08-31
- Add "inline" option to RadioSet
- NPM audit fix
## [5.1.0] - 2020-08-25
- Add new Switch component
- Swap checkboxes for switches on Wi-Fi page
- Decrease button width on different breakpoints
- Add integration of Prettier + ESLint + reForis Style Guide
- Add appropriate links to dropdown headers
- Add semantic & accessibility structure for headings
- NPM audit & Update packages
- GitLab CI: image update to node 10
## [5.0.3] - 2020-09-23
- Fixes issue with WebSockets
## [5.0.2] - 2020-09-22
- Fix infinity loop caused by WebSockets
## [5.0.1] - 2020-07-21
- Fix Wi-Fi Form
- NPM audit fix & update of packages
## [5.0.0] - 2020-05-07
- I've realized that it should be major update due to broken API.
## [4.5.1] - 2020-05-07
- Add initialData to ForisForm children.
- Update translations .pot file.
## [4.5.0] - 2020-03-25
- Use exposed pdfmake.
- NPM audit fix & update of packages.
## [4.4.0] - 2020-03-13
- Update domain validation.
## [4.3.1] - 2020-03-06
- Add logout link.
## [4.3.0] - 2020-02-26
- Allow RadioSet accept elements as children.
- Add option to make modal scrollable.
## [4.2.0] - 2020-02-21
- Add translations.
- Improve datatime localization.
## [4.1.0] - 2020-02-20
- Added date and time utilities.
## [4.0.0] - 2020-02-20
- Throw an error if unhandled exception happens during API request.
## [3.4.0] - 2020-02-17
- Display actual GET error response within the form.
- Added styles extracted from reForis.
- Added reference to form element (for programmatically submitting it).
## [3.2.0] - 2020-01-17
- Swapped react-router with react-router-dom. Prepared Foris JS for using
react-router-dom exposed by reForis.
- Added controller ID filter to WebSocket hook.
- Updated translation messages after moving WiFi form.
- Increased request timeout to 30.5 sec.
## [3.1.1] - 2020-01-10
- Fixed package dependencies related to exposing libraries via reForis
## [3.1.0] - 2020-01-09
- Added Wi-Fi settings form
- Fixed path to index.js file in package.json
## [3.0.0] - 2020-01-07
- Removal of Babel compiler
- Fixed width of ForisForm, removed default sizing for form widgets (like
buttons)
## [2.1.1] - 2020-01-06
- Display date and time picker above input element
## [2.1.0] - 2019-12-19
- Set WebSocket logging to debug level
- Added hook that detects clicking outside of component
- Added Radio to list of publicly available components
- Fixed link to git repository in package.json
## [2.0.0] - 2019-12-09
- Added dynamic suffix for API URLs (allowing to use one hook for different
resources with e.g. PUT)
- Added unsubscribe method to WebSocket client
- Added custom class to SpinnerElement
- Improved documentation
- Published README.md
## [1.4.0] - 2019-11-29
- Add reboot button.
- Fix Foris URLs prefixes
## [1.3.3] - 2019-11-22
- Add translations from Weblate.
## [1.3.2] - 2019-11-20
- Expose only AlertContext.
- Add hook for API pooling.
## [1.3.1] - 2019-11-14
## [1.2.0] - 2019-10-24
## [1.1.0] - 2019-10-22
## [1.0.0] - 2019-10-07
## [0.0.7] - 2019-09-02
[unreleased]:
https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.7.1...dev
[6.7.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.7.0...v6.7.1
[6.7.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.6.2...v6.7.0
[6.6.2]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.6.1...v6.6.2
[6.6.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.6.0...v6.6.1
[6.6.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.5.0...v6.6.0
[6.5.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.4.0...v6.5.0
[6.4.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.3.0...v6.4.0
[6.3.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.2.1...v6.3.0
[6.2.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.2.0...v6.2.1
[6.2.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.1.1...v6.2.0
[6.1.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.1.0...v6.1.1
[6.1.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.0.3...v6.1.0
[6.0.3]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.0.2...v6.0.3
[6.0.2]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.0.1...v6.0.2
[6.0.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v6.0.0...v6.0.1
[6.0.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.6.1...v6.0.0
[5.6.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.6.0...v5.6.1
[5.6.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.5.0...v5.6.0
[5.5.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.4.1...v5.5.0
[5.4.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.4.0...v5.4.1
[5.4.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.3.0...v5.4.0
[5.3.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.2.0...v5.3.0
[5.2.0]:
https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.16...v5.2.0
[5.1.16]:
https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.15...v5.1.16
[5.1.15]:
https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.14...v5.1.15
[5.1.14]:
https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.13...v5.1.14
[5.1.13]:
https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.12...v5.1.13
[5.1.12]:
https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.11...v5.1.12
[5.1.11]:
https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.10...v5.1.11
[5.1.10]:
https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.9...v5.1.10
[5.1.9]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.8...v5.1.9
[5.1.8]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.7...v5.1.8
[5.1.7]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.6...v5.1.7
[5.1.6]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.5...v5.1.6
[5.1.5]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.4...v5.1.5
[5.1.4]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.3...v5.1.4
[5.1.3]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.2...v5.1.3
[5.1.2]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.1...v5.1.2
[5.1.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.1.0...v5.1.1
[5.1.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.0.3...v5.1.0
[5.0.3]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.0.2...v5.0.3
[5.0.2]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.0.1...v5.0.2
[5.0.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v5.5.0...v5.0.1
[5.0.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v4.5.1...v5.0.0
[4.5.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v4.5.0...v4.5.1
[4.5.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v4.4.0...v4.5.0
[4.4.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v4.3.1...v4.4.0
[4.3.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v4.3.0...v4.3.1
[4.3.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v4.2.0...v4.3.0
[4.2.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v4.1.0...v4.2.0
[4.1.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v4.0.0...v4.1.0
[4.0.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v3.4.0...v4.0.0
[3.4.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v3.2.0...v3.4.0
[3.2.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v3.1.1...v3.2.0
[3.1.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v3.1.0...v3.1.1
[3.1.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v3.0.0...v3.1.0
[3.0.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v2.1.1...v3.0.0
[2.1.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v2.1.0...v2.1.1
[2.1.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v2.0.0...v2.1.0
[2.0.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v1.4.0...v2.0.0
[1.4.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v1.3.3...v1.4.0
[1.3.3]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v1.3.2...v1.3.3
[1.3.2]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v1.3.1...v1.3.2
[1.3.1]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v1.2.0...v1.3.1
[1.2.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v1.1.0...v1.2.0
[1.1.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v1.0.0...v1.1.0
[1.0.0]: https://gitlab.nic.cz/turris/reforis/foris-js/-/compare/v0.0.7...v1.0.0
[0.0.7]: https://gitlab.nic.cz/turris/reforis/foris-js/-/tags/v0.0.7

View File

@ -11,7 +11,6 @@ MSGID_BUGS_ADDRESS="tech.support@turris.cz"
DEV_PYTHON=python3
VENV_NAME?=venv
JS_DIR=js
VENV_BIN=$(shell pwd)/$(VENV_NAME)/bin
.PHONY: all

View File

@ -33,4 +33,5 @@ To install a specific version:
npm install foris@version
```
[![npm version](https://badge.fury.io/js/foris.svg)](https://badge.fury.io/js/foris)
<a target="_blank" href="https://www.npmjs.com/package/foris">Check
on<img width="100px" src="./docs/forisjs-npm.svg"></a>

View File

@ -19,9 +19,11 @@ module.exports = {
collectCoverageFrom: ["src/**/*.{js,jsx}"],
coverageDirectory: "coverage",
testPathIgnorePatterns: ["/node_modules/", "/__fixtures__/", "/dist/"],
testEnvironment: "jsdom",
verbose: false,
setupFilesAfterEnv: ["<rootDir>/src/testUtils/setup"],
setupFilesAfterEnv: [
"@testing-library/react/cleanup-after-each",
"<rootDir>/src/testUtils/setup",
],
globals: {
TZ: "utc",
},

30793
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "foris",
"version": "6.7.1",
"version": "5.5.0",
"description": "Foris JS library is a set of components and utils for reForis application and plugins.",
"author": "CZ.NIC, z.s.p.o.",
"repository": {
@ -14,53 +14,49 @@
"license": "GPL-3.0",
"main": "./src/index.js",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.7.9",
"immutability-helper": "^3.1.1",
"moment": "^2.30.1",
"qrcode.react": "^4.2.0",
"react-datetime": "^3.3.1",
"react-uid": "^2.4.0"
"axios": "^0.21.1",
"immutability-helper": "3.0.1",
"moment": "^2.24.0",
"qrcode.react": "^1.0.1",
"react-datetime": "^3.1.1",
"react-uid": "^2.2.0"
},
"peerDependencies": {
"bootstrap": "^5.3.3",
"bootstrap": "4.4.1",
"prop-types": "15.8.1",
"react": "16.9.0",
"react-dom": "16.9.0",
"react-router-dom": "^5.1.2"
},
"devDependencies": {
"@babel/cli": "^7.26.4",
"@babel/core": "^7.26.9",
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@testing-library/react": "^12.1.5",
"babel-loader": "^9.2.1",
"@babel/cli": "^7.12.10",
"@babel/core": "^7.9.0",
"@babel/plugin-transform-runtime": "^7.9.0",
"@babel/preset-env": "^7.9.0",
"@babel/preset-react": "^7.9.4",
"@fortawesome/fontawesome-free": "^5.13.0",
"@testing-library/react": "^8.0.9",
"babel-loader": "^8.1.0",
"babel-polyfill": "^6.26.0",
"bootstrap": "^5.3.3",
"css-loader": "^7.1.2",
"eslint": "^8.57.0",
"eslint-config-reforis": "^2.2.1",
"bootstrap": "^4.5.0",
"css-loader": "^5.2.4",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-config-reforis": "^1.0.0",
"eslint-plugin-prettier": "^3.1.4",
"file-loader": "^6.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-mock-axios": "^4.8.0",
"moment-timezone": "^0.5.47",
"prettier": "^3.5.3",
"jest": "^25.2.0",
"jest-mock-axios": "^3.2.0",
"moment-timezone": "^0.5.34",
"prettier": "2.0.5",
"prop-types": "15.8.1",
"react": "16.9.0",
"react-dom": "16.9.0",
"react-router-dom": "^5.1.2",
"react-styleguidist": "^12.0.1",
"snapshot-diff": "^0.10.0",
"style-loader": "^4.0.0",
"webpack": "^5.98.0"
"react-styleguidist": "^11.2.0",
"snapshot-diff": "^0.7.0",
"style-loader": "^1.2.1",
"webpack": "^5.68.0"
},
"scripts": {
"lint": "eslint src",
@ -71,4 +67,4 @@
"docs": "npx styleguidist build ",
"docs:watch": "styleguidist server"
}
}
}

View File

@ -1 +0,0 @@
module.exports = require("eslint-config-reforis/prettier.config");

View File

@ -1,16 +1,15 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useState, useContext, useCallback, useMemo } from "react";
import React, { useState, useContext, useCallback } from "react";
import PropTypes from "prop-types";
import Alert, { ALERT_TYPES } from "../../bootstrap/Alert";
import Portal from "../../utils/Portal";
import { Alert, ALERT_TYPES } from "../bootstrap/Alert";
import { Portal } from "../utils/Portal";
AlertContextProvider.propTypes = {
children: PropTypes.oneOfType([
@ -31,10 +30,6 @@ function AlertContextProvider({ children }) {
);
const dismissAlert = useCallback(() => setAlert(null), [setAlert]);
const contextValue = useMemo(
() => [setAlertWrapper, dismissAlert],
[setAlertWrapper, dismissAlert]
);
return (
<>
@ -45,7 +40,7 @@ function AlertContextProvider({ children }) {
</Alert>
</Portal>
)}
<AlertContext.Provider value={contextValue}>
<AlertContext.Provider value={[setAlertWrapper, dismissAlert]}>
{children}
</AlertContext.Provider>
</>

View File

@ -43,17 +43,14 @@ describe("AlertContext", () => {
expect(componentContainer).toMatchSnapshot();
});
it("should dismiss alert with alert button", async () => {
it("should dismiss alert with alert button", () => {
fireEvent.click(getByText(componentContainer, "Set alert"));
// Alert is present
expect(getByText(componentContainer, "Alert content")).toBeDefined();
fireEvent.click(componentContainer.querySelector(".btn-close"));
fireEvent.click(componentContainer.querySelector(".close"));
// Alert is gone
await (() =>
expect(
queryByText(componentContainer, "Alert content")
).toBeNull());
expect(queryByText(componentContainer, "Alert content")).toBeNull();
});
it("should dismiss alert with external button", () => {

View File

@ -6,14 +6,14 @@ exports[`AlertContext should render alert 1`] = `
id="alert-container"
>
<div
class="alert alert-danger alert-fade-in alert-dismissible"
role="alert"
class="alert alert-dismissible alert-danger"
>
<button
aria-label="Close"
class="btn-close"
class="close"
type="button"
/>
>
×
</button>
Alert content
</div>
</div>

View File

@ -111,8 +111,9 @@ const useAPIPatch = createAPIHook("PATCH");
const useAPIPut = createAPIHook("PUT");
const useAPIDelete = createAPIHook("DELETE");
/* eslint-disable default-param-last */
function useAPIPolling(endpoint, delay = 1000, until) {
export { useAPIGet, useAPIPost, useAPIPatch, useAPIPut, useAPIDelete };
export function useAPIPolling(endpoint, delay = 1000, until) {
// delay ms
const [state, setState] = useState({ state: API_STATE.INIT });
const [getResponse, get] = useAPIGet(endpoint);
@ -132,12 +133,3 @@ function useAPIPolling(endpoint, delay = 1000, until) {
return [state];
}
export {
useAPIGet,
useAPIPost,
useAPIPatch,
useAPIPut,
useAPIDelete,
useAPIPolling,
};

View File

@ -1,16 +1,13 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useRef, useEffect, useState } from "react";
import React from "react";
import PropTypes from "prop-types";
import { useFocusTrap } from "../utils/hooks";
export const ALERT_TYPES = Object.freeze({
PRIMARY: "primary",
SECONDARY: "secondary",
@ -38,44 +35,21 @@ Alert.defaultProps = {
type: ALERT_TYPES.DANGER,
};
function Alert({ type, onDismiss, children }) {
const alertRef = useRef();
const [isVisible, setIsVisible] = useState(true);
useFocusTrap(alertRef, !!onDismiss);
useEffect(() => {
if (onDismiss) {
const timeout = setTimeout(() => setIsVisible(false), 7000);
return () => clearTimeout(timeout);
}
}, [onDismiss]);
const handleAnimationEnd = () => {
if (!isVisible && onDismiss) {
onDismiss();
}
};
export function Alert({ type, onDismiss, children }) {
return (
<div
ref={alertRef}
className={`alert alert-${type} ${isVisible ? "alert-fade-in" : "alert-slide-out-top"} ${
className={`alert ${
onDismiss ? "alert-dismissible" : ""
}`.trim()}
role="alert"
onAnimationEnd={handleAnimationEnd}
} alert-${type}`}
>
{onDismiss && (
<button
type="button"
className="btn-close"
onClick={() => setIsVisible(false)}
aria-label={_("Close")}
/>
{onDismiss ? (
<button type="button" className="close" onClick={onDismiss}>
&times;
</button>
) : (
false
)}
{children}
</div>
);
}
export default Alert;

View File

@ -1,12 +1,11 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
Button.propTypes = {
@ -25,28 +24,31 @@ Button.propTypes = {
]).isRequired,
};
function Button({ className, loading, forisFormSize, children, ...props }) {
let buttonClass = className ? `btn ${className}` : "btn btn-primary";
export function Button({
className,
loading,
forisFormSize,
children,
...props
}) {
let buttonClass = className ? `btn ${className}` : "btn btn-primary ";
if (forisFormSize) {
buttonClass = `${buttonClass} col-12 col-md-3 col-lg-2`;
buttonClass = `${buttonClass} col-sm-12 col-md-3 col-lg-2`;
}
const span = loading ? (
<span
className="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
/>
) : null;
return (
<button
type="button"
className={`${buttonClass} d-inline-flex justify-content-center align-items-center`}
{...props}
>
{loading && (
<span
className="spinner-border spinner-border-sm me-1"
role="status"
aria-hidden="true"
/>
)}
<span>{children}</span>
<button type="button" className={buttonClass} {...props}>
{span}
{span ? " " : null}
{children}
</button>
);
}
export default Button;

View File

@ -1,12 +1,11 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import { useUID } from "react-uid";
@ -17,36 +16,33 @@ CheckBox.propTypes = {
helpText: PropTypes.string,
/** Control if checkbox is clickable */
disabled: PropTypes.bool,
/** Additional class name */
className: PropTypes.string,
};
CheckBox.defaultProps = {
disabled: false,
};
function CheckBox({ label, helpText, disabled, className, ...props }) {
export function CheckBox({ label, helpText, disabled, ...props }) {
const uid = useUID();
return (
<div className={`${className || "mb-3"} form-check`.trim()}>
<input
className="form-check-input"
type="checkbox"
id={uid}
disabled={disabled}
{...props}
/>
<label className="form-check-label" htmlFor={uid}>
{label}
</label>
{helpText && (
<div className="form-text">
<small>{helpText}</small>
</div>
)}
<div className="form-group">
<div className="custom-control custom-checkbox ">
<input
className="custom-control-input"
type="checkbox"
id={uid}
disabled={disabled}
{...props}
/>
<label className="custom-control-label" htmlFor={uid}>
{label}
{helpText && (
<small className="form-text text-muted">
{helpText}
</small>
)}
</label>
</div>
</div>
);
}
export default CheckBox;

View File

@ -1,15 +1,13 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2022 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useState, useRef } from "react";
import PropTypes from "prop-types";
import Input from "./Input";
import { Input } from "./Input";
CopyInput.propTypes = {
/** Field label. */
@ -24,7 +22,7 @@ CopyInput.propTypes = {
readOnly: PropTypes.bool,
};
function CopyInput({ value, ...props }) {
export function CopyInput({ value, ...props }) {
const inputTextRef = useRef();
const [isCopied, setIsCopied] = useState(false);
@ -48,15 +46,15 @@ function CopyInput({ value, ...props }) {
return (
<Input type="text" value={value} ref={inputTextRef} {...props}>
<button
className="btn btn-outline-secondary"
type="button"
onClick={handleCopyClick}
>
<span>{isCopied ? _("Copied!") : _("Copy")}</span>
</button>
<div className="input-group-append">
<button
className="btn btn-outline-secondary"
type="button"
onClick={handleCopyClick}
>
<span>{isCopied ? _("Copied!") : _("Copy")}</span>
</button>
</div>
</Input>
);
}
export default CopyInput;

View File

@ -1,19 +1,18 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import moment from "moment/moment";
import PropTypes from "prop-types";
import Datetime from "react-datetime";
import moment from "moment/moment";
import "react-datetime/css/react-datetime.css";
import "./DataTimeInput.css";
import Input from "./Input";
import { Input } from "./Input";
DataTimeInput.propTypes = {
/** Field label. */
@ -38,7 +37,7 @@ DataTimeInput.propTypes = {
const DEFAULT_DATE_FORMAT = "YYYY-MM-DD";
const DEFAULT_TIME_FORMAT = "HH:mm:ss";
function DataTimeInput({
export function DataTimeInput({
value,
onChange,
isValidDate,
@ -47,13 +46,13 @@ function DataTimeInput({
children,
...props
}) {
const renderInput = (datetimeProps) => {
function renderInput(datetimeProps) {
return (
<Input {...props} {...datetimeProps}>
{children}
</Input>
);
};
}
return (
<Datetime
@ -71,5 +70,3 @@ function DataTimeInput({
/>
);
}
export default DataTimeInput;

View File

@ -1,12 +1,11 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
DownloadButton.propTypes = {
@ -22,7 +21,7 @@ DownloadButton.defaultProps = {
className: "btn-primary",
};
function DownloadButton({ href, className, children, ...props }) {
export function DownloadButton({ href, className, children, ...props }) {
return (
<a
href={href}
@ -34,5 +33,3 @@ function DownloadButton({ href, className, children, ...props }) {
</a>
);
}
export default DownloadButton;

View File

@ -6,14 +6,11 @@
*/
import React from "react";
import PropTypes from "prop-types";
import Input from "./Input";
import { Input } from "./Input";
function EmailInput({ ...props }) {
return <Input type="email" {...props} />;
}
export const EmailInput = ({ ...props }) => <Input type="email" {...props} />;
EmailInput.propTypes = {
/** Field label. */
@ -25,5 +22,3 @@ EmailInput.propTypes = {
/** Email value. */
value: PropTypes.string,
};
export default EmailInput;

View File

@ -6,7 +6,6 @@ All additional `props` are passed to the `<input type="email">` HTML component.
```js
import { useState } from "react";
import Button from "./Button";
const [email, setEmail] = useState("Wrong email");
<form onSubmit={(e) => e.preventDefault()}>
<EmailInput
@ -15,6 +14,6 @@ const [email, setEmail] = useState("Wrong email");
helpText="Read the small text!"
onChange={(event) => setEmail(event.target.value)}
/>
<Button type="submit">Try to submit</Button>
<button type="submit">Try to submit</button>
</form>;
```

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
@ -8,8 +8,7 @@
import React from "react";
import PropTypes from "prop-types";
import Input from "./Input";
import { Input } from "./Input";
FileInput.propTypes = {
/** Field label. */
@ -24,7 +23,7 @@ FileInput.propTypes = {
multiple: PropTypes.bool,
};
function FileInput({ ...props }) {
export function FileInput({ ...props }) {
return (
<Input
type="file"
@ -35,5 +34,3 @@ function FileInput({ ...props }) {
/>
);
}
export default FileInput;

View File

@ -6,12 +6,11 @@
*/
import React, { forwardRef } from "react";
import PropTypes from "prop-types";
import { useUID } from "react-uid";
import PropTypes from "prop-types";
/** Base bootstrap input component. */
const Input = forwardRef(
export const Input = forwardRef(
(
{
type,
@ -28,23 +27,18 @@ const Input = forwardRef(
) => {
const uid = useUID();
const inputClassName = `${className || ""} ${
const inputClassName = `form-control ${className || ""} ${
error ? "is-invalid" : ""
}`.trim();
return (
<div className="mb-3">
{label && (
<label
className={`form-label ${labelClassName || ""}`.trim()}
htmlFor={uid}
>
{label}
</label>
)}
<div className="form-group">
<label className={labelClassName} htmlFor={uid}>
{label}
</label>
<div className={`input-group ${groupClassName || ""}`.trim()}>
<input
className={`form-control ${inputClassName}`.trim()}
className={inputClassName}
type={type}
id={uid}
ref={ref}
@ -52,22 +46,18 @@ const Input = forwardRef(
/>
{children}
</div>
{error && <div className="invalid-feedback">{error}</div>}
{helpText && (
<div className="form-text">
<small>{helpText}</small>
</div>
)}
{error ? <div className="invalid-feedback">{error}</div> : null}
{helpText ? (
<small className="form-text text-muted">{helpText}</small>
) : null}
</div>
);
}
);
Input.displayName = "Input";
Input.propTypes = {
type: PropTypes.string.isRequired,
label: PropTypes.string,
label: PropTypes.string.isRequired,
helpText: PropTypes.string,
error: PropTypes.string,
className: PropTypes.string,
@ -78,5 +68,3 @@ Input.propTypes = {
labelClassName: PropTypes.string,
groupClassName: PropTypes.string,
};
export default Input;

View File

@ -1,16 +1,15 @@
/*
* Copyright (C) 2020-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2020 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useRef, useEffect } from "react";
import PropTypes from "prop-types";
import { useClickOutside, useFocusTrap } from "../utils/hooks";
import Portal from "../utils/Portal";
import { Portal } from "../utils/Portal";
import { useClickOutside } from "../utils/hooks";
import "./Modal.css";
Modal.propTypes = {
@ -29,11 +28,10 @@ Modal.propTypes = {
};
export function Modal({ shown, setShown, scrollable, size, children }) {
const modalRef = useRef();
const dialogRef = useRef();
let modalSize = "modal-";
useClickOutside(modalRef, () => setShown(false));
useFocusTrap(modalRef, shown);
useClickOutside(dialogRef, () => setShown(false));
useEffect(() => {
const handleEsc = (event) => {
@ -66,13 +64,11 @@ export function Modal({ shown, setShown, scrollable, size, children }) {
return (
<Portal containerId="modal-container">
<div
ref={modalRef}
className={`modal fade ${shown ? "show" : ""}`.trim()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
ref={dialogRef}
className={`${modalSize.trim()} modal-dialog modal-dialog-centered ${
scrollable ? "modal-dialog-scrollable" : ""
}`.trim()}
@ -88,21 +84,19 @@ export function Modal({ shown, setShown, scrollable, size, children }) {
ModalHeader.propTypes = {
setShown: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
showCloseButton: PropTypes.bool,
};
export function ModalHeader({ setShown, title, showCloseButton = true }) {
export function ModalHeader({ setShown, title }) {
return (
<div className="modal-header">
<h1 className="modal-title fs-5">{title}</h1>
{showCloseButton && (
<button
type="button"
className="btn-close"
onClick={() => setShown(false)}
aria-label={_("Close")}
/>
)}
<h5 className="modal-title">{title}</h5>
<button
type="button"
className="close"
onClick={() => setShown(false)}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
);
}

View File

@ -1,18 +1,15 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import PropTypes from "prop-types";
import Input from "./Input";
import { useConditionalTimeout } from "../utils/hooks";
import { Input } from "./Input";
import "./NumberInput.css";
NumberInput.propTypes = {
@ -26,7 +23,7 @@ NumberInput.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/** Function called when value changes. */
onChange: PropTypes.func.isRequired,
/** Additional description displayed to the right of input value. */
/** Additional description dispaled to the right of input value. */
inlineText: PropTypes.string,
};
@ -34,7 +31,7 @@ NumberInput.defaultProps = {
value: 0,
};
function NumberInput({ onChange, inlineText, value, ...props }) {
export function NumberInput({ onChange, inlineText, value, ...props }) {
function updateValue(initialValue, difference) {
onChange({ target: { value: initialValue + difference } });
}
@ -50,61 +47,29 @@ function NumberInput({ onChange, inlineText, value, ...props }) {
-1
);
function handleKeyDown(event, enableFunction) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
enableFunction(true);
}
}
function handleKeyUp(event, enableFunction) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
enableFunction(false);
}
}
return (
<Input type="number" onChange={onChange} value={value} {...props}>
{inlineText && (
<span className="input-group-text">{inlineText}</span>
)}
<button
type="button"
className="btn btn-outline-secondary"
onMouseDown={() => enableIncrease(true)}
onMouseUp={() => enableIncrease(false)}
onMouseLeave={() => enableIncrease(false)}
onTouchStart={() => enableIncrease(true)}
onTouchEnd={() => enableIncrease(false)}
onTouchCancel={() => enableIncrease(false)}
onKeyDown={(event) => handleKeyDown(event, enableIncrease)}
onKeyUp={(event) => handleKeyUp(event, enableIncrease)}
onBlur={() => enableIncrease(false)}
title={_("Increase value. Hint: Hold to increase faster.")}
aria-label={_("Increase value. Hint: Hold to increase faster.")}
>
<FontAwesomeIcon icon={faPlus} />
</button>
<button
type="button"
className="btn btn-outline-secondary"
onMouseDown={() => enableDecrease(true)}
onMouseUp={() => enableDecrease(false)}
onMouseLeave={() => enableDecrease(false)}
onTouchStart={() => enableDecrease(true)}
onTouchEnd={() => enableDecrease(false)}
onTouchCancel={() => enableDecrease(false)}
onKeyDown={(event) => handleKeyDown(event, enableDecrease)}
onKeyUp={(event) => handleKeyUp(event, enableDecrease)}
onBlur={() => enableDecrease(false)}
title={_("Decrease value. Hint: Hold to decrease faster.")}
aria-label={_("Decrease value. Hint: Hold to decrease faster.")}
>
<FontAwesomeIcon icon={faMinus} />
</button>
<div className="input-group-append">
{inlineText && <p className="input-group-text">{inlineText}</p>}
<button
type="button"
className="btn btn-outline-secondary"
onMouseDown={() => enableIncrease(true)}
onMouseUp={() => enableIncrease(false)}
aria-label="Increase"
>
<i className="fas fa-plus" />
</button>
<button
type="button"
className="btn btn-outline-secondary"
onMouseDown={() => enableDecrease(true)}
onMouseUp={() => enableDecrease(false)}
aria-label="Decrease"
>
<i className="fas fa-minus" />
</button>
</div>
</Input>
);
}
export default NumberInput;

View File

@ -1,17 +1,14 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2022 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useState } from "react";
import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import PropTypes from "prop-types";
import Input from "./Input";
import { Input } from "./Input";
PasswordInput.propTypes = {
/** Field label. */
@ -28,7 +25,7 @@ PasswordInput.propTypes = {
newPass: PropTypes.bool,
};
function PasswordInput({ withEye, newPass, ...props }) {
export function PasswordInput({ withEye, newPass, ...props }) {
const [isHidden, setHidden] = useState(true);
return (
@ -37,24 +34,24 @@ function PasswordInput({ withEye, newPass, ...props }) {
autoComplete={newPass ? "new-password" : "current-password"}
{...props}
>
{withEye && (
<button
type="button"
className="input-group-text"
onClick={(e) => {
e.preventDefault();
setHidden((shouldBeHidden) => !shouldBeHidden);
}}
>
<FontAwesomeIcon
icon={isHidden ? faEye : faEyeSlash}
style={{ width: "1.25rem" }}
className="text-secondary"
/>
</button>
)}
{withEye ? (
<div className="input-group-append">
<button
type="button"
className="input-group-text"
onClick={(e) => {
e.preventDefault();
setHidden((shouldBeHidden) => !shouldBeHidden);
}}
>
<i
className={`fa ${
isHidden ? "fa-eye" : "fa-eye-slash"
}`}
/>
</button>
</div>
) : null}
</Input>
);
}
export default PasswordInput;

View File

@ -1,48 +0,0 @@
/*
* Copyright (C) 2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
Radio.propTypes = {
label: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element,
PropTypes.node,
PropTypes.arrayOf(PropTypes.node),
]).isRequired,
id: PropTypes.string.isRequired,
inline: PropTypes.bool,
helpText: PropTypes.string,
className: PropTypes.string,
};
function Radio({ label, id, helpText, inline, className, ...props }) {
return (
<div
className={`${className || "mb-3"} ${inline ? "form-check form-check-inline" : ""}`.trim()}
>
<input
id={id}
className="form-check-input me-2"
type="radio"
{...props}
/>
<label className="form-check-label" htmlFor={id}>
{label}
{helpText && (
<div className="form-text">
<small>{helpText}</small>
</div>
)}
</label>
</div>
);
}
export default Radio;

View File

@ -1,17 +1,14 @@
/*
* Copyright (C) 2020-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2020 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import { useUID } from "react-uid";
import Radio from "./Radio";
RadioSet.propTypes = {
/** Name attribute of the input HTML tag. */
name: PropTypes.string.isRequired,
@ -20,7 +17,7 @@ RadioSet.propTypes = {
/** Choices . */
choices: PropTypes.arrayOf(
PropTypes.shape({
/** Choice label . */
/** Choice lable . */
label: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element,
@ -39,7 +36,15 @@ RadioSet.propTypes = {
inline: PropTypes.bool,
};
function RadioSet({ name, label, choices, value, helpText, inline, ...props }) {
export function RadioSet({
name,
label,
choices,
value,
helpText,
inline,
...props
}) {
const uid = useUID();
const radios = choices.map((choice, key) => {
const id = `${name}-${key}`;
@ -59,7 +64,7 @@ function RadioSet({ name, label, choices, value, helpText, inline, ...props }) {
});
return (
<div className="mb-3">
<div className="form-group">
{label && (
<label htmlFor={uid} className="d-block">
{label}
@ -67,12 +72,47 @@ function RadioSet({ name, label, choices, value, helpText, inline, ...props }) {
)}
{radios}
{helpText && (
<div className="form-text">
<small>{helpText}</small>
</div>
<small className="form-text text-muted">{helpText}</small>
)}
</div>
);
}
export default RadioSet;
Radio.propTypes = {
label: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element,
PropTypes.node,
PropTypes.arrayOf(PropTypes.node),
]).isRequired,
id: PropTypes.string.isRequired,
inline: PropTypes.bool,
helpText: PropTypes.string,
};
export function Radio({ label, id, helpText, inline, ...props }) {
return (
<>
<div
className={`custom-control custom-radio ${
inline ? "custom-control-inline" : ""
}`.trim()}
>
<input
id={id}
className="custom-control-input"
type="radio"
{...props}
/>
<label className="custom-control-label" htmlFor={id}>
{label}
</label>
{helpText && (
<small className="form-text text-muted mt-0 mb-3">
{helpText}
</small>
)}
</div>
</>
);
}

View File

@ -1,12 +1,11 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2022 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import { useUID } from "react-uid";
@ -21,7 +20,7 @@ Select.propTypes = {
helpText: PropTypes.string,
};
function Select({ label, choices, helpText, ...props }) {
export function Select({ label, choices, helpText, ...props }) {
const uid = useUID();
const options = Object.keys(choices).map((choice) => (
@ -31,20 +30,14 @@ function Select({ label, choices, helpText, ...props }) {
));
return (
<div className="mb-3">
<label className="form-label" htmlFor={uid}>
{label}
</label>
<select className="form-select" id={uid} {...props}>
<div className="form-group">
<label htmlFor={uid}>{label}</label>
<select className="custom-select" id={uid} {...props}>
{options}
</select>
{helpText && (
<div className="form-text">
<small>{helpText}</small>
</div>
)}
{helpText ? (
<small className="form-text text-muted">{helpText}</small>
) : null}
</div>
);
}
export default Select;

View File

@ -1,22 +1,16 @@
.spinner-fs-wrapper {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1101; /* increase z-index by 1 to ensure it's on top of spinner-fs-background */
}
.spinner-wrapper .spinner-border {
width: 4rem;
height: 4rem;
color: var(--bs-primary);
color: #00a2e2;
}
.spinner-fs-background {
background-color: rgba(2, 2, 2, 0.5);
color: rgb(230, 230, 230);
width: 100vw;
height: 100vh;
position: fixed;
width: 100%;
height: 100%;
top: 0;
display: flex;
align-items: center;
@ -37,7 +31,3 @@
.spinner-fs-wrapper .spinner-text {
margin: 1rem;
}
.spinner-border-sm {
min-width: 16px;
}

View File

@ -6,7 +6,6 @@
*/
import React from "react";
import PropTypes from "prop-types";
import "./Spinner.css";
@ -17,7 +16,7 @@ Spinner.propTypes = {
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]),
/** Render component with full-screen mode (using appropriate `.css` styles) */
/** Render component with full-screen mode (using apropriate `.css` styles) */
fullScreen: PropTypes.bool.isRequired,
className: PropTypes.string,
};

View File

@ -1,12 +1,11 @@
/*
* Copyright (c) 2020-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (c) 2020 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import { useUID } from "react-uid";
@ -19,35 +18,32 @@ Switch.propTypes = {
]).isRequired,
helpText: PropTypes.string,
switchHeading: PropTypes.bool,
className: PropTypes.string,
};
function Switch({ label, helpText, switchHeading, className, ...props }) {
export function Switch({ label, helpText, switchHeading, ...props }) {
const uid = useUID();
return (
<div
className={`form-check form-switch ${className || "mb-3"} ${
switchHeading ? "d-flex align-items-center" : ""
}`.trim()}
>
<input
type="checkbox"
className={`form-check-input ${switchHeading ? "me-2" : ""}`.trim()}
role="switch"
id={uid}
{...props}
/>
<label className="form-check-label" htmlFor={uid}>
{label}
</label>
{helpText && (
<div className="form-text">
<small>{helpText}</small>
</div>
)}
<div className={`form-group ${switchHeading ? "switch" : ""}`.trim()}>
<div
className={`custom-control custom-switch ${
!helpText ? "custom-control-inline" : ""
} ${switchHeading ? "switch-heading" : ""}`.trim()}
>
<input
type="checkbox"
className="custom-control-input"
id={uid}
{...props}
/>
<label className="custom-control-label" htmlFor={uid}>
{label}
</label>
{helpText && (
<small className="form-text text-muted mt-0 mb-3">
{helpText}
</small>
)}
</div>
</div>
);
}
export default Switch;

View File

@ -1,19 +1,16 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import Input from "./Input";
import { Input } from "./Input";
function TextInput({ ...props }) {
return <Input type="text" {...props} />;
}
export const TextInput = ({ ...props }) => <Input type="text" {...props} />;
TextInput.propTypes = {
/** Field label. */
@ -23,5 +20,3 @@ TextInput.propTypes = {
/** Help text message. */
helpText: PropTypes.string,
};
export default TextInput;

View File

@ -1,42 +0,0 @@
/*
* Copyright (C) 2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import { faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import PropTypes from "prop-types";
import Button from "./Button";
ThreeDotsMenu.propTypes = {
/** Menu items. */
children: PropTypes.arrayOf(PropTypes.node).isRequired,
};
function ThreeDotsMenu({ children, ...props }) {
return (
<div className="dropdown position-static" {...props}>
<Button
className="btn-sm btn-link text-body"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<FontAwesomeIcon icon={faEllipsisVertical} />
</Button>
<ul className="dropdown-menu">
{children.map((child) => (
<li key={child.key || child.props.id || Math.random()}>
{child}
</li>
))}
</ul>
</div>
);
}
export default ThreeDotsMenu;

View File

@ -1,40 +0,0 @@
ThreeDotsMenu Bootstrap component is a dropdown menu that appears when the user
clicks on three dots. It is used to display a list of actions that can be
performed on a particular item.
```js
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEdit, faTrash } from "@fortawesome/free-solid-svg-icons";
const threeDotsMenuItems = [
{
text: "Edit",
icon: faEdit,
onClick: () => {
alert("Edit clicked");
},
},
{
text: "Delete",
icon: faTrash,
onClick: () => {
alert("Delete clicked");
},
},
];
<ThreeDotsMenu>
{threeDotsMenuItems.map((item, index) => (
<button key={index} onClick={item.onClick} className="dropdown-item">
<FontAwesomeIcon
icon={item.icon}
className="me-1"
width="1rem"
size="sm"
/>
{item.text}
</button>
))}
</ThreeDotsMenu>;
```

View File

@ -9,7 +9,7 @@ import React from "react";
import { render } from "customTestRender";
import Button from "../Button";
import { Button } from "../Button";
describe("<Button />", () => {
it("Render button correctly", () => {

View File

@ -9,7 +9,7 @@ import React from "react";
import { render } from "customTestRender";
import CheckBox from "../CheckBox";
import { CheckBox } from "../CheckBox";
describe("<Checkbox/>", () => {
it("Render checkbox", () => {

View File

@ -9,7 +9,7 @@ import React from "react";
import { render } from "customTestRender";
import DownloadButton from "../DownloadButton";
import { DownloadButton } from "../DownloadButton";
describe("<DownloadButton />", () => {
it("should have download attribute", () => {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
@ -7,9 +7,9 @@
import React from "react";
import { render, fireEvent, getByLabelText, waitFor } from "customTestRender";
import { render, fireEvent, getByLabelText, wait } from "customTestRender";
import NumberInput from "../NumberInput";
import { NumberInput } from "../NumberInput";
describe("<NumberInput/>", () => {
const onChangeMock = jest.fn();
@ -32,17 +32,17 @@ describe("<NumberInput/>", () => {
});
it("Increase number with button", async () => {
const increaseButton = getByLabelText(componentContainer, /Increase/);
const increaseButton = getByLabelText(componentContainer, "Increase");
fireEvent.mouseDown(increaseButton);
await waitFor(() =>
await wait(() =>
expect(onChangeMock).toHaveBeenCalledWith({ target: { value: 2 } })
);
});
it("Decrease number with button", async () => {
const decreaseButton = getByLabelText(componentContainer, /Decrease/);
const decreaseButton = getByLabelText(componentContainer, "Decrease");
fireEvent.mouseDown(decreaseButton);
await waitFor(() =>
await wait(() =>
expect(onChangeMock).toHaveBeenCalledWith({ target: { value: 0 } })
);
});

View File

@ -9,7 +9,7 @@ import React from "react";
import { render } from "customTestRender";
import PasswordInput from "../PasswordInput";
import { PasswordInput } from "../PasswordInput";
describe("<PasswordInput/>", () => {
it("Render password input", () => {

View File

@ -9,7 +9,7 @@ import React from "react";
import { render } from "customTestRender";
import RadioSet from "../RadioSet";
import { RadioSet } from "../RadioSet";
const TEST_CHOICES = [
{

View File

@ -14,7 +14,7 @@ import {
render,
} from "customTestRender";
import Select from "../Select";
import { Select } from "../Select";
const TEST_CHOICES = {
1: "one",

View File

@ -9,7 +9,7 @@ import React from "react";
import { render } from "customTestRender";
import Switch from "../Switch";
import { Switch } from "../Switch";
describe("<Switch/>", () => {
it("Render switch", () => {

View File

@ -9,7 +9,7 @@ import React from "react";
import { render } from "customTestRender";
import TextInput from "../TextInput";
import { TextInput } from "../TextInput";
describe("<TextInput/>", () => {
it("Render text input", () => {

View File

@ -2,38 +2,33 @@
exports[`<Button /> Render button correctly 1`] = `
<button
class="btn btn-primary d-inline-flex justify-content-center align-items-center"
class="btn btn-primary "
type="button"
>
<span>
Test Button
</span>
Test Button
</button>
`;
exports[`<Button /> Render button with custom classes 1`] = `
<button
class="btn one two three d-inline-flex justify-content-center align-items-center"
class="btn one two three"
type="button"
>
<span>
Test Button
</span>
Test Button
</button>
`;
exports[`<Button /> Render button with spinner 1`] = `
<button
class="btn btn-primary d-inline-flex justify-content-center align-items-center"
class="btn btn-primary "
type="button"
>
<span
aria-hidden="true"
class="spinner-border spinner-border-sm me-1"
class="spinner-border spinner-border-sm"
role="status"
/>
<span>
Test Button
</span>
Test Button
</button>
`;

View File

@ -2,51 +2,55 @@
exports[`<Checkbox/> Render checkbox 1`] = `
<div
class="mb-3 form-check"
class="form-group"
>
<input
checked=""
class="form-check-input"
id="1"
type="checkbox"
/>
<label
class="form-check-label"
for="1"
>
Test label
</label>
<div
class="form-text"
class="custom-control custom-checkbox "
>
<small>
Some help text
</small>
<input
checked=""
class="custom-control-input"
id="1"
type="checkbox"
/>
<label
class="custom-control-label"
for="1"
>
Test label
<small
class="form-text text-muted"
>
Some help text
</small>
</label>
</div>
</div>
`;
exports[`<Checkbox/> Render uncheked checkbox 1`] = `
<div
class="mb-3 form-check"
class="form-group"
>
<input
class="form-check-input"
id="1"
type="checkbox"
/>
<label
class="form-check-label"
for="1"
>
Test label
</label>
<div
class="form-text"
class="custom-control custom-checkbox "
>
<small>
Some help text
</small>
<input
class="custom-control-input"
id="1"
type="checkbox"
/>
<label
class="custom-control-label"
for="1"
>
Test label
<small
class="form-text text-muted"
>
Some help text
</small>
</label>
</div>
</div>
`;

View File

@ -2,10 +2,9 @@
exports[`<NumberInput/> Render number input 1`] = `
<div
class="mb-3"
class="form-group"
>
<label
class="form-label"
for="1"
>
Test label
@ -19,33 +18,33 @@ exports[`<NumberInput/> Render number input 1`] = `
type="number"
value="1"
/>
<button
aria-label="Increase value. Hint: Hold to increase faster."
class="btn btn-outline-secondary"
title="Increase value. Hint: Hold to increase faster."
type="button"
<div
class="input-group-append"
>
<i
class="fa"
/>
</button>
<button
aria-label="Decrease value. Hint: Hold to decrease faster."
class="btn btn-outline-secondary"
title="Decrease value. Hint: Hold to decrease faster."
type="button"
>
<i
class="fa"
/>
</button>
<button
aria-label="Increase"
class="btn btn-outline-secondary"
type="button"
>
<i
class="fas fa-plus"
/>
</button>
<button
aria-label="Decrease"
class="btn btn-outline-secondary"
type="button"
>
<i
class="fas fa-minus"
/>
</button>
</div>
</div>
<div
class="form-text"
<small
class="form-text text-muted"
>
<small>
Some help text
</small>
</div>
Some help text
</small>
</div>
`;

View File

@ -2,10 +2,9 @@
exports[`<PasswordInput/> Render password input 1`] = `
<div
class="mb-3"
class="form-group"
>
<label
class="form-label"
for="1"
>
Test label
@ -21,12 +20,10 @@ exports[`<PasswordInput/> Render password input 1`] = `
value="Some password"
/>
</div>
<div
class="form-text"
<small
class="form-text text-muted"
>
<small>
Some help text
</small>
</div>
Some help text
</small>
</div>
`;

View File

@ -2,7 +2,7 @@
exports[`<RadioSet/> Render radio set 1`] = `
<div
class="mb-3"
class="form-group"
>
<label
class="d-block"
@ -11,63 +11,61 @@ exports[`<RadioSet/> Render radio set 1`] = `
Radios set label
</label>
<div
class="mb-3"
class="custom-control custom-radio"
>
<input
checked=""
class="form-check-input me-2"
class="custom-control-input"
id="test_name-0"
name="test_name"
type="radio"
value="value"
/>
<label
class="form-check-label"
class="custom-control-label"
for="test_name-0"
>
label
</label>
</div>
<div
class="mb-3"
class="custom-control custom-radio"
>
<input
class="form-check-input me-2"
class="custom-control-input"
id="test_name-1"
name="test_name"
type="radio"
value="another value"
/>
<label
class="form-check-label"
class="custom-control-label"
for="test_name-1"
>
another label
</label>
</div>
<div
class="mb-3"
class="custom-control custom-radio"
>
<input
class="form-check-input me-2"
class="custom-control-input"
id="test_name-2"
name="test_name"
type="radio"
value="another on value"
/>
<label
class="form-check-label"
class="custom-control-label"
for="test_name-2"
>
another one label
</label>
</div>
<div
class="form-text"
<small
class="form-text text-muted"
>
<small>
Some help text
</small>
</div>
Some help text
</small>
</div>
`;

View File

@ -3,16 +3,15 @@
exports[`<Select/> Test with snapshot. 1`] = `
<div>
<div
class="mb-3"
class="form-group"
>
<label
class="form-label"
for="1"
>
Test label
</label>
<select
class="form-select"
class="custom-select"
id="1"
>
<option
@ -31,13 +30,11 @@ exports[`<Select/> Test with snapshot. 1`] = `
three
</option>
</select>
<div
class="form-text"
<small
class="form-text text-muted"
>
<small>
Help text
</small>
</div>
Help text
</small>
</div>
</div>
`;

View File

@ -2,25 +2,26 @@
exports[`<Switch/> Render switch 1`] = `
<div
class="form-check form-switch mb-3"
class="form-group"
>
<input
checked=""
class="form-check-input"
id="1"
role="switch"
type="checkbox"
/>
<label
class="form-check-label"
for="1"
>
Test label
</label>
<div
class="form-text"
class="custom-control custom-switch"
>
<small>
<input
checked=""
class="custom-control-input"
id="1"
type="checkbox"
/>
<label
class="custom-control-label"
for="1"
>
Test label
</label>
<small
class="form-text text-muted mt-0 mb-3"
>
Some help text
</small>
</div>
@ -29,24 +30,25 @@ exports[`<Switch/> Render switch 1`] = `
exports[`<Switch/> Render uncheked switch 1`] = `
<div
class="form-check form-switch mb-3"
class="form-group"
>
<input
class="form-check-input"
id="1"
role="switch"
type="checkbox"
/>
<label
class="form-check-label"
for="1"
>
Test label
</label>
<div
class="form-text"
class="custom-control custom-switch"
>
<small>
<input
class="custom-control-input"
id="1"
type="checkbox"
/>
<label
class="custom-control-label"
for="1"
>
Test label
</label>
<small
class="form-text text-muted mt-0 mb-3"
>
Some help text
</small>
</div>

View File

@ -2,10 +2,9 @@
exports[`<TextInput/> Render text input 1`] = `
<div
class="mb-3"
class="form-group"
>
<label
class="form-label"
for="1"
>
Test label
@ -20,12 +19,10 @@ exports[`<TextInput/> Render text input 1`] = `
value="Some text"
/>
</div>
<div
class="form-text"
<small
class="form-text text-muted"
>
<small>
Some help text
</small>
</div>
Some help text
</small>
</div>
`;

View File

@ -1,12 +1,11 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2021 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
/** Bootstrap column size for form fields */
const formFieldsSize = "card p-4 col-sm-12 col-lg-12 p-0 mb-4";
const buttonFormFieldsSize = "col-sm-12 col-lg-12 p-0 mb-3";
export { formFieldsSize, buttonFormFieldsSize };
// eslint-disable-next-line import/prefer-default-export
export const formFieldsSize = "card p-4 col-sm-12 col-lg-12 p-0 mb-4";
export const buttonFormFieldsSize = "col-sm-12 col-lg-12 p-0 mb-3";

View File

@ -1,157 +0,0 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { useAPIPost, useAPIPut } from "../../api/hooks";
import { API_STATE } from "../../api/utils";
import Button from "../../bootstrap/Button";
import {
Modal,
ModalHeader,
ModalBody,
ModalFooter,
} from "../../bootstrap/Modal";
import { useAlert } from "../../context/alertContext/AlertContext";
ActionButtonWithModal.propTypes = {
/** Component that triggers the action. */
actionTrigger: PropTypes.elementType.isRequired,
/** Method to use for the action. */
actionMethod: PropTypes.string,
/** URL to send the action to. */
actionUrl: PropTypes.string.isRequired,
/** Title of the modal. */
modalTitle: PropTypes.string.isRequired,
/** Message of the modal. */
modalMessage: PropTypes.string.isRequired,
/** Text of the action button in the modal. */
modalActionText: PropTypes.string,
/** Props for the action button in the modal. */
modalActionProps: PropTypes.object,
/** Message to display on successful action. */
successMessage: PropTypes.string,
/** Message to display on failed action. */
errorMessage: PropTypes.string,
};
function ActionButtonWithModal({
actionTrigger: ActionTriggerComponent,
actionMethod = "POST",
actionUrl,
modalTitle,
modalMessage,
modalActionText,
modalActionProps,
successMessage,
errorMessage,
}) {
const [triggered, setTriggered] = useState(false);
const [modalShown, setModalShown] = useState(false);
const [triggerPostActionStatus, triggerPostAction] = useAPIPost(actionUrl);
const [triggerPutActionStatus, triggerPutAction] = useAPIPut(actionUrl);
const [setAlert] = useAlert();
useEffect(() => {
if (
triggerPostActionStatus.state === API_STATE.SUCCESS ||
triggerPutActionStatus.state === API_STATE.SUCCESS
) {
setAlert(
successMessage || _("Action successful."),
API_STATE.SUCCESS
);
setTriggered(false);
}
if (
triggerPostActionStatus.state === API_STATE.ERROR ||
triggerPutActionStatus.state === API_STATE.ERROR
) {
setAlert(errorMessage || _("Action failed."));
setTriggered(false);
}
}, [
triggerPostActionStatus,
triggerPutActionStatus,
setAlert,
successMessage,
errorMessage,
]);
const actionHandler = () => {
setTriggered(true);
if (actionMethod === "POST") {
triggerPostAction();
} else {
triggerPutAction();
}
setModalShown(false);
};
return (
<>
<ActionModal
shown={modalShown}
setShown={setModalShown}
onAction={actionHandler}
title={modalTitle}
message={modalMessage}
actionText={modalActionText}
actionProps={modalActionProps}
/>
<ActionTriggerComponent
loading={triggered}
disabled={triggered}
onClick={() => setModalShown(true)}
/>
</>
);
}
ActionModal.propTypes = {
shown: PropTypes.bool.isRequired,
setShown: PropTypes.func.isRequired,
onAction: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
actionText: PropTypes.string,
actionProps: PropTypes.object,
};
function ActionModal({
shown,
setShown,
onAction,
title,
message,
actionText,
actionProps,
}) {
return (
<Modal shown={shown} setShown={setShown}>
<ModalHeader setShown={setShown} title={title} />
<ModalBody>
<p className="mb-0">{message}</p>
</ModalBody>
<ModalFooter>
<Button
className="btn-secondary"
onClick={() => setShown(false)}
>
{_("Cancel")}
</Button>
<Button onClick={onAction} {...actionProps}>
{actionText || _("Confirm")}
</Button>
</ModalFooter>
</Modal>
);
}
export default ActionButtonWithModal;

View File

@ -1,39 +0,0 @@
RebootButton component is a button that opens a modal dialog to confirm the
reboot of the device.
## Usage
```jsx
import React, { useEffect, createContext } from "react";
import Button from "../../bootstrap/Button";
import { AlertContextProvider } from "../../context/alertContext/AlertContext";
import ActionButtonWithModal from "./ActionButtonWithModal";
window.AlertContext = React.createContext();
const RebootButtonExample = () => {
const ActionButton = (props) => {
return <Button {...props}>Action</Button>;
};
return (
<AlertContextProvider>
<div id="modal-container" />
<div id="alert-container" />
<ActionButtonWithModal
actionTrigger={ActionButton}
actionUrl="/reforis/api/action"
modalTitle="Warning!"
modalMessage="Are you sure you want to perform this action?"
modalActionText="Confirm action"
modalActionProps={{ className: "btn-danger" }}
successMessage="Action request succeeded."
errorMessage="Action request failed."
/>
</AlertContextProvider>
);
};
<RebootButtonExample />;
```

View File

@ -0,0 +1,78 @@
/*
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { useAPIPost } from "../api/hooks";
import { API_STATE } from "../api/utils";
import { ForisURLs } from "../utils/forisUrls";
import { Button } from "../bootstrap/Button";
import { Modal, ModalHeader, ModalBody, ModalFooter } from "../bootstrap/Modal";
import { useAlert } from "../alertContext/AlertContext";
export function RebootButton(props) {
const [triggered, setTriggered] = useState(false);
const [modalShown, setModalShown] = useState(false);
const [triggerRebootStatus, triggerReboot] = useAPIPost(ForisURLs.reboot);
const [setAlert] = useAlert();
useEffect(() => {
if (triggerRebootStatus.state === API_STATE.ERROR) {
setAlert(_("Reboot request failed."));
}
});
function rebootHandler() {
setTriggered(true);
triggerReboot();
setModalShown(false);
}
return (
<>
<RebootModal
shown={modalShown}
setShown={setModalShown}
onReboot={rebootHandler}
/>
<Button
className="btn-danger"
loading={triggered}
disabled={triggered}
onClick={() => setModalShown(true)}
{...props}
>
{_("Reboot")}
</Button>
</>
);
}
RebootModal.propTypes = {
shown: PropTypes.bool.isRequired,
setShown: PropTypes.func.isRequired,
onReboot: PropTypes.func.isRequired,
};
function RebootModal({ shown, setShown, onReboot }) {
return (
<Modal shown={shown} setShown={setShown}>
<ModalHeader setShown={setShown} title={_("Warning!")} />
<ModalBody>
<p>{_("Are you sure you want to restart the router?")}</p>
</ModalBody>
<ModalFooter>
<Button onClick={() => setShown(false)}>{_("Cancel")}</Button>
<Button className="btn-danger" onClick={onReboot}>
{_("Confirm reboot")}
</Button>
</ModalFooter>
</Modal>
);
}

View File

@ -1,118 +0,0 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useMemo, useState } from "react";
import { rankItem } from "@tanstack/match-sorter-utils";
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import PropTypes from "prop-types";
import RichTableBody from "./RichTableBody";
import RichTableColumnsDropdown from "./RichTableColumnsDropdown";
import RichTableHeader from "./RichTableHeader";
import RichTablePagination from "./RichTablePagination";
import Input from "../../bootstrap/Input";
RichTable.propTypes = {
/** Columns to be displayed in the table */
columns: PropTypes.array.isRequired,
/** Data to be displayed in the table, must be passed as a stable reference, for example, useState */
data: PropTypes.array.isRequired,
/** Whether to display pagination */
withPagination: PropTypes.bool,
/** Number of rows per page, the default is 5 */
pageSize: PropTypes.number,
/** Index of the current page */
pageIndex: PropTypes.number,
};
export default function RichTable({
columns,
data,
withPagination,
pageSize = 5,
pageIndex = 0,
}) {
const tableColumns = useMemo(() => columns, [columns]);
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({
pageIndex,
pageSize,
});
const [globalFilter, setGlobalFilter] = useState("");
const [columnVisibility, setColumnVisibility] = useState({});
const table = useReactTable({
data,
columns: tableColumns,
filterFns: {
fuzzy: fuzzyFilter,
},
globalFilterFn: "fuzzy",
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onPaginationChange: setPagination,
onGlobalFilterChange: setGlobalFilter,
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
pagination,
globalFilter,
columnVisibility,
},
});
const paginationIsNeeded = data.length > pageSize && withPagination;
return (
<div>
<div className="d-flex justify-content-between align-items-center">
<Input
className="me-3"
type="text"
placeholder={_("Search…")}
value={globalFilter ?? ""}
onChange={(e) => setGlobalFilter(String(e.target.value))}
/>
<RichTableColumnsDropdown columns={table.getAllLeafColumns()} />
</div>
<div className="table-responsive">
<table className="table table-hover text-nowrap">
<RichTableHeader table={table} flexRender={flexRender} />
<RichTableBody
table={table}
columns={tableColumns}
flexRender={flexRender}
/>
</table>
{paginationIsNeeded && (
<RichTablePagination
table={table}
tablePageSize={pageSize}
allRows={data.length}
/>
)}
</div>
</div>
);
}
function fuzzyFilter(row, columnId, value, addMeta) {
const itemRank = rankItem(row.getValue(columnId), value);
addMeta({ itemRank });
return itemRank.passed;
}

View File

@ -1,135 +0,0 @@
### Description
Rich Table is a table component based on
[Tanstack React Table](https://tanstack.com/table/). It adds some features to
the table component, such as:
- **Pagination**: The table can be paginated.
- **Sorting**: The table can be sorted by columns.
- **Row Expansion**: The table rows can be expanded. (To be implemented)
### Example
```js
import RichTable from "./RichTable";
const columns = [
{
header: "Name",
accessorKey: "name",
},
{
header: "Surname",
accessorKey: "surname",
},
{
header: "Age",
accessorKey: "age",
},
{
header: "Phone",
accessorKey: "phone",
},
];
const data = [
{
name: "John",
surname: "Coltrane",
age: 30,
phone: "123456789",
},
{
name: "Jane",
surname: "Doe",
age: 25,
phone: "987654321",
},
{
name: "Alice",
surname: "Smith",
age: 35,
phone: "123456789",
},
{
name: "Bob",
surname: "Smith",
age: 40,
phone: "987654321",
},
{
name: "Charlie",
surname: "Brown",
age: 45,
phone: "123456789",
},
{
name: "Daisy",
surname: "Brown",
age: 50,
phone: "987654321",
},
{
name: "Eve",
surname: "Johnson",
age: 55,
phone: "123456789",
},
{
name: "Frank",
surname: "Johnson",
age: 60,
phone: "987654321",
},
{
name: "Grace",
surname: "Williams",
age: 65,
phone: "123456789",
},
{
name: "Henry",
surname: "Williams",
age: 70,
phone: "987654321",
},
{
name: "Ivy",
surname: "Brown",
age: 75,
phone: "123456789",
},
{
name: "Jack",
surname: "Brown",
age: 80,
phone: "987654321",
},
{
name: "Kelly",
surname: "Johnson",
age: 85,
phone: "123456789",
},
{
name: "Liam",
surname: "Johnson",
age: 90,
phone: "987654321",
},
{
name: "Mia",
surname: "Williams",
age: 95,
phone: "123456789",
},
{
name: "Nathan",
surname: "Williams",
age: 100,
phone: "987654321",
},
];
<RichTable columns={columns} data={data} withPagination />;
```

View File

@ -1,58 +0,0 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import propTypes from "prop-types";
RichTableBody.propTypes = {
table: propTypes.shape({
getRowModel: propTypes.func.isRequired,
}).isRequired,
columns: propTypes.array.isRequired,
flexRender: propTypes.func.isRequired,
};
function RichTableBody({ table, columns, flexRender }) {
return (
<tbody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
return (
<tr key={row.id} className="align-middle">
{row.getVisibleCells().map((cell) => {
return (
<td
key={cell.id}
{...(cell.column.columnDef
.className && {
className:
cell.column.columnDef.className,
})}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
);
})}
</tr>
);
})
) : (
<tr>
<td colSpan={columns.length} className="text-center py-4">
<span>{_("No results.")}</span>
</td>
</tr>
)}
</tbody>
);
}
export default RichTableBody;

View File

@ -1,90 +0,0 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import { faCheck, faRotateLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import PropTypes from "prop-types";
import Button from "../../bootstrap/Button";
RichTableColumnsDropdown.propTypes = {
columns: PropTypes.array.isRequired,
};
function RichTableColumnsDropdown({ columns }) {
return (
<div className="dropdown mb-3">
<Button
className="btn btn-outline-secondary dropdown-toggle"
data-bs-toggle="dropdown"
>
{_("Columns")}
</Button>
<ul className="dropdown-menu dropdown-menu-end">
{columns.map((column) => {
return (
<li key={column.id}>
<button
type="button"
className="dropdown-item d-flex align-items-center"
onClick={column.getToggleVisibilityHandler()}
style={{ paddingLeft: "2rem" }}
disabled={
column.columnDef?.enableHiding === false
}
>
{column.getIsVisible() && (
<FontAwesomeIcon
icon={faCheck}
className="position-absolute text-secondary me-2"
style={{ left: "0.6rem" }}
width="1rem"
/>
)}
<span>{column.columnDef.header}</span>
</button>
</li>
);
})}
{columns.some((column) => !column.getIsVisible()) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
<li>
<button
type="button"
className="dropdown-item d-flex align-items-center"
style={{ paddingLeft: "2rem" }}
onClick={() => {
// toggleVisibility for columns that are hidden
columns.forEach((column) => {
if (!column.getIsVisible()) {
column.toggleVisibility();
}
});
}}
>
<FontAwesomeIcon
icon={faRotateLeft}
className="position-absolute text-secondary me-2"
width="1rem"
style={{ left: "0.6rem" }}
/>
{_("Reset")}
</button>
</li>
</>
)}
</ul>
</div>
);
}
export default RichTableColumnsDropdown;

View File

@ -1,102 +0,0 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import {
faSquareCaretUp,
faSquareCaretDown,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import propTypes from "prop-types";
RichTableHeader.propTypes = {
table: propTypes.shape({
getHeaderGroups: propTypes.func.isRequired,
}).isRequired,
flexRender: propTypes.func.isRequired,
};
function RichTableHeader({ table, flexRender }) {
const getThTitle = (header) => {
if (!header.column.getCanSort()) return undefined;
const nextSortingOrder = header.column.getNextSortingOrder();
if (nextSortingOrder === "asc") return _("Sort ascending");
if (nextSortingOrder === "desc") return _("Sort descending");
return _("Clear sort");
};
return (
<thead className="table-light">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} role="row">
{headerGroup.headers.map((header) => (
<th
key={header.id}
colSpan={header.colSpan}
{...(header.column.columnDef.headerClassName && {
className:
header.column.columnDef.headerClassName,
})}
>
{header.isPlaceholder ||
header.column.columnDef.headerIsHidden ? (
<div className="d-none" aria-hidden="true">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
<button
type="button"
style={
header.column.columnDef
.headerClassName === "text-center"
? { justifySelf: "center" }
: {}
}
className={`btn btn-link text-decoration-none text-reset fw-bold p-0 d-flex align-items-center
${
header.column.getCanSort()
? "d-flex align-items-center"
: ""
}
`}
onClick={header.column.getToggleSortingHandler()}
title={getThTitle(header)}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: (
<FontAwesomeIcon
icon={faSquareCaretUp}
className="ms-1 text-primary"
/>
),
desc: (
<FontAwesomeIcon
icon={faSquareCaretDown}
className="ms-1 text-primary"
/>
),
}[header.column.getIsSorted()] ?? null}
</button>
)}
</th>
))}
</tr>
))}
</thead>
);
}
export default RichTableHeader;

View File

@ -1,128 +0,0 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useMemo } from "react";
import {
faAngleLeft,
faAnglesLeft,
faAngleRight,
faAnglesRight,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import propTypes from "prop-types";
RichTablePagination.propTypes = {
table: propTypes.shape({
getState: propTypes.func.isRequired,
getCanPreviousPage: propTypes.func.isRequired,
getCanNextPage: propTypes.func.isRequired,
firstPage: propTypes.func.isRequired,
previousPage: propTypes.func.isRequired,
nextPage: propTypes.func.isRequired,
lastPage: propTypes.func.isRequired,
setPageSize: propTypes.func.isRequired,
getPageCount: propTypes.func.isRequired,
}).isRequired,
tablePageSize: propTypes.number,
allRows: propTypes.number,
};
function RichTablePagination({ table, tablePageSize, allRows }) {
const { pagination } = table.getState();
const prevPagBtnDisabled = !table.getCanPreviousPage();
const nextPagBtnDisabled = !table.getCanNextPage();
const pageSizes = useMemo(() => {
return [tablePageSize ?? 5, 10, 25].filter(
(value, index, self) => self.indexOf(value) === index
);
}, [tablePageSize]);
const renderPaginationButton = (icon, ariaLabel, onClick, disabled) => (
<li
className={`page-item ${disabled ? "disabled" : ""}`}
style={{ cursor: disabled ? "not-allowed" : "pointer" }}
>
<button
type="button"
className="page-link"
aria-label={ariaLabel}
onClick={onClick}
disabled={disabled}
>
<FontAwesomeIcon icon={icon} />
</button>
</li>
);
return (
<nav
aria-label={_("Pagination navigation bar")}
className="d-flex gap-2 justify-content-start align-items-center mx-2 mb-1 text-nowrap"
>
<ul className="pagination pagination-sm mb-0">
{renderPaginationButton(
faAnglesLeft,
_("First page"),
() => table.firstPage(),
prevPagBtnDisabled
)}
{renderPaginationButton(
faAngleLeft,
_("Previous page"),
() => table.previousPage(),
prevPagBtnDisabled
)}
{renderPaginationButton(
faAngleRight,
_("Next page"),
() => table.nextPage(),
nextPagBtnDisabled
)}
{renderPaginationButton(
faAnglesRight,
_("Last page"),
() => table.lastPage(),
nextPagBtnDisabled
)}
</ul>
<span>
{_("Page")}&nbsp;
<span className="fw-bold">
{pagination.pageIndex + 1}
&nbsp;{_("of")}&nbsp;
{table.getPageCount().toLocaleString()}
</span>
</span>
<div
className="vr mx-1 align-self-center"
style={{ height: "1.5rem" }}
/>
<span>{_("Rows per page:")}</span>
<select
className="form-select form-select-sm w-auto"
aria-label={_("Select rows per page")}
value={pagination.pageSize}
onChange={(e) => {
table.setPageSize(Number(e.target.value));
}}
>
{pageSizes.map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
<option key={allRows} value={allRows}>
{_("All")}
</option>
</select>
</nav>
);
}
export default RichTablePagination;

View File

@ -1,27 +1,26 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2022 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import { Button } from "../../bootstrap/Button";
import { useAlert } from "../../alertContext/AlertContext";
import { ALERT_TYPES } from "../../bootstrap/Alert";
import { useAPIPost } from "../../api/hooks";
import { API_STATE } from "../../api/utils";
import { ALERT_TYPES } from "../../bootstrap/Alert";
import Button from "../../bootstrap/Button";
import { formFieldsSize } from "../../bootstrap/constants";
import { useAlert } from "../../context/alertContext/AlertContext";
ResetWiFiSettings.propTypes = {
ws: PropTypes.object.isRequired,
endpoint: PropTypes.string.isRequired,
};
function ResetWiFiSettings({ ws, endpoint }) {
export function ResetWiFiSettings({ ws, endpoint }) {
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@ -45,11 +44,11 @@ function ResetWiFiSettings({ ws, endpoint }) {
}
}, [postResetResponse, setAlert]);
const onReset = () => {
function onReset() {
dismissAlert();
setIsLoading(true);
postReset();
};
}
return (
<div className={formFieldsSize}>
@ -59,7 +58,7 @@ function ResetWiFiSettings({ ws, endpoint }) {
"If a number of wireless cards doesn't match, you may try to reset the Wi-Fi settings. Note that this will remove the current Wi-Fi configuration and restore the default values."
)}
</p>
<div className="text-end">
<div className="text-right">
<Button
className="btn-primary"
forisFormSize
@ -73,5 +72,3 @@ function ResetWiFiSettings({ ws, endpoint }) {
</div>
);
}
export default ResetWiFiSettings;

View File

@ -1,22 +1,20 @@
/*
* Copyright (C) 2019-2022 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2021 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import { HELP_TEXTS, HTMODES, BANDS, ENCRYPTIONMODES } from "./constants";
import WifiGuestForm from "./WiFiGuestForm";
import { Switch } from "../../bootstrap/Switch";
import { PasswordInput } from "../../bootstrap/PasswordInput";
import { RadioSet } from "../../bootstrap/RadioSet";
import { Select } from "../../bootstrap/Select";
import { TextInput } from "../../bootstrap/TextInput";
import WiFiQRCode from "./WiFiQRCode";
import PasswordInput from "../../bootstrap/PasswordInput";
import RadioSet from "../../bootstrap/RadioSet";
import Select from "../../bootstrap/Select";
import Switch from "../../bootstrap/Switch";
import TextInput from "../../bootstrap/TextInput";
import WifiGuestForm from "./WiFiGuestForm";
import { HELP_TEXTS, HTMODES, HWMODES, ENCRYPTIONMODES } from "./constants";
WiFiForm.propTypes = {
formData: PropTypes.shape({ devices: PropTypes.arrayOf(PropTypes.object) })
@ -60,13 +58,13 @@ DeviceForm.propTypes = {
SSID: PropTypes.string.isRequired,
password: PropTypes.string.isRequired,
hidden: PropTypes.bool.isRequired,
band: PropTypes.string.isRequired,
hwmode: PropTypes.string.isRequired,
htmode: PropTypes.string.isRequired,
channel: PropTypes.string.isRequired,
guest_wifi: PropTypes.object.isRequired,
encryption: PropTypes.string.isRequired,
available_bands: PropTypes.array.isRequired,
ieee80211w_disabled: PropTypes.bool,
ieee80211w_disabled: PropTypes.bool.isRequired,
}),
formErrors: PropTypes.object.isRequired,
setFormValue: PropTypes.func.isRequired,
@ -94,7 +92,7 @@ function DeviceForm({
return (
<>
<Switch
label={<h2 className="mb-0">{_(`Wi-Fi ${deviceID + 1}`)}</h2>}
label={<h2>{_(`Wi-Fi ${deviceID + 1}`)}</h2>}
checked={formData.enabled}
onChange={setFormValue((value) => ({
devices: {
@ -121,10 +119,12 @@ function DeviceForm({
}))}
{...props}
>
<WiFiQRCode
SSID={formData.SSID}
password={formData.password}
/>
<div className="input-group-append">
<WiFiQRCode
SSID={formData.SSID}
password={formData.password}
/>
</div>
</TextInput>
<PasswordInput
@ -155,26 +155,26 @@ function DeviceForm({
/>
<RadioSet
name={`band-${deviceID}`}
label={_("Band")}
choices={getBandChoices(formData)}
value={formData.band}
helpText={HELP_TEXTS.band}
name={`hwmode-${deviceID}`}
label="GHz"
choices={getHwmodeChoices(formData)}
value={formData.hwmode}
helpText={HELP_TEXTS.hwmode}
inline
onChange={setFormValue((value) => {
// Find the selected band
const selectedBand = bnds.find(
(band) => band.band === value
);
// Get the last item in the available HT modes for the selected band
const bestHtmode =
selectedBand.available_htmodes.slice(-1)[0];
// Get the last item in an array of available HT modes
const [best2] = bnds[0].available_htmodes.slice(-1);
const [best5] = bnds[1].available_htmodes.slice(-1);
return {
devices: {
[deviceIndex]: {
band: { $set: value },
hwmode: { $set: value },
channel: { $set: "0" },
htmode: { $set: bestHtmode },
htmode: {
$set:
// Set HT mode depending on checked frequency
value === "11a" ? best5 : best2,
},
},
},
};
@ -263,14 +263,14 @@ function getChannelChoices(device) {
};
device.available_bands.forEach((availableBand) => {
if (availableBand.band !== device.band) return;
if (availableBand.hwmode !== device.hwmode) return;
availableBand.available_channels.forEach((availableChannel) => {
channelChoices[availableChannel.number.toString()] = `
${availableChannel.number}
(${availableChannel.frequency} MHz ${
availableChannel.radar ? " ,DFS" : ""
})
availableChannel.radar ? " ,DFS" : ""
})
`;
});
});
@ -282,7 +282,7 @@ function getHtmodeChoices(device) {
const htmodeChoices = {};
device.available_bands.forEach((availableBand) => {
if (availableBand.band !== device.band) return;
if (availableBand.hwmode !== device.hwmode) return;
availableBand.available_htmodes.forEach((availableHtmod) => {
htmodeChoices[availableHtmod] = HTMODES[availableHtmod];
@ -291,10 +291,10 @@ function getHtmodeChoices(device) {
return htmodeChoices;
}
function getBandChoices(device) {
function getHwmodeChoices(device) {
return device.available_bands.map((availableBand) => ({
label: `${BANDS[availableBand.band]} GHz`,
value: availableBand.band,
label: HWMODES[availableBand.hwmode],
value: availableBand.hwmode,
}));
}

View File

@ -1,20 +1,18 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import { HELP_TEXTS, ENCRYPTIONMODES } from "./constants";
import { TextInput } from "../../bootstrap/TextInput";
import { Switch } from "../../bootstrap/Switch";
import { PasswordInput } from "../../bootstrap/PasswordInput";
import WiFiQRCode from "./WiFiQRCode";
import PasswordInput from "../../bootstrap/PasswordInput";
import Select from "../../bootstrap/Select";
import Switch from "../../bootstrap/Switch";
import TextInput from "../../bootstrap/TextInput";
import { HELP_TEXTS } from "./constants";
WifiGuestForm.propTypes = {
formData: PropTypes.shape({
@ -22,7 +20,6 @@ WifiGuestForm.propTypes = {
SSID: PropTypes.string.isRequired,
password: PropTypes.string.isRequired,
enabled: PropTypes.bool.isRequired,
encryption: PropTypes.string.isRequired,
}),
formErrors: PropTypes.shape({
SSID: PropTypes.string,
@ -70,11 +67,14 @@ export default function WifiGuestForm({
}))}
{...props}
>
<WiFiQRCode
SSID={formData.SSID}
password={formData.password}
/>
<div className="input-group-append">
<WiFiQRCode
SSID={formData.SSID}
password={formData.password}
/>
</div>
</TextInput>
<PasswordInput
withEye
label={_("Password")}
@ -91,20 +91,6 @@ export default function WifiGuestForm({
}))}
{...props}
/>
<Select
label={_("Encryption")}
choices={ENCRYPTIONMODES}
helpText={HELP_TEXTS.wpa3}
value={formData.encryption}
onChange={setFormValue((value) => ({
devices: {
[formData.id]: {
guest_wifi: { encryption: { $set: value } },
},
},
}))}
{...props}
/>
</>
) : null}
</>

View File

@ -1,30 +1,31 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import QRCode from "qrcode.react";
import PropTypes from "prop-types";
import { QRCodeSVG } from "qrcode.react";
import { createAndDownloadPdf, toQRCodeContent } from "./qrCodeHelpers";
import Button from "../../bootstrap/Button";
import { ForisURLs } from "../../utils/forisUrls";
import { Button } from "../../bootstrap/Button";
import {
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from "../../bootstrap/Modal";
import { createAndDownloadPdf, toQRCodeContent } from "./qrCodeHelpers";
WiFiQRCode.propTypes = {
SSID: PropTypes.string.isRequired,
password: PropTypes.string.isRequired,
};
const QR_ICON_PATH = `${ForisURLs.static}/imgs/QR_icon.svg`;
export default function WiFiQRCode({ SSID, password }) {
const [modal, setModal] = useState(false);
@ -33,23 +34,26 @@ export default function WiFiQRCode({ SSID, password }) {
<button
type="button"
className="input-group-text"
onClick={() => setModal(true)}
onClick={(e) => {
e.preventDefault();
setModal(true);
}}
>
<FontAwesomeIcon
icon="fa-solid fa-qrcode"
title={_("Show QR code")}
aria-label={_("Show QR code")}
className="text-secondary"
<img
width="20"
src={QR_ICON_PATH}
alt="QR"
style={{ opacity: 0.67 }}
/>
</button>
{modal && (
{modal ? (
<QRCodeModal
setShown={setModal}
shown={modal}
SSID={SSID}
password={password}
/>
)}
) : null}
</>
);
}
@ -66,35 +70,24 @@ function QRCodeModal({ shown, setShown, SSID, password }) {
<Modal setShown={setShown} shown={shown}>
<ModalHeader setShown={setShown} title={_("Wi-Fi QR Code")} />
<ModalBody>
<QRCodeSVG
className="d-block mx-auto img-logo-black"
<QRCode
renderAs="svg"
value={toQRCodeContent(SSID, password)}
level="M"
size={350}
marginSize={0}
imageSettings={{
src: "/reforis/static/reforis/imgs/turris.svg",
height: 40,
width: 40,
excavate: true,
}}
includeMargin
style={{ display: "block", margin: "auto" }}
/>
</ModalBody>
<ModalFooter>
<Button
className="btn-secondary"
onClick={() => setShown(false)}
className="btn-outline-primary"
onClick={(e) => {
e.preventDefault();
createAndDownloadPdf(SSID, password);
}}
>
{_("Close")}
</Button>
<Button
className="btn-primary"
onClick={() => createAndDownloadPdf(SSID, password)}
>
<FontAwesomeIcon
icon="fa-solid fa-file-download"
className="me-2"
/>
<i className="fas fa-arrow-down mr-2" />
{_("Download PDF")}
</Button>
</ModalFooter>

View File

@ -1,17 +1,16 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2021 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import ResetWiFiSettings from "./ResetWiFiSettings";
import { ForisForm } from "../../form/components/ForisForm";
import WiFiForm from "./WiFiForm";
import ForisForm from "../../form/components/ForisForm";
import { ResetWiFiSettings } from "./ResetWiFiSettings";
WiFiSettings.propTypes = {
ws: PropTypes.object.isRequired,
@ -20,7 +19,7 @@ WiFiSettings.propTypes = {
hasGuestNetwork: PropTypes.bool,
};
function WiFiSettings({ ws, endpoint, resetEndpoint, hasGuestNetwork }) {
export function WiFiSettings({ ws, endpoint, resetEndpoint, hasGuestNetwork }) {
return (
<>
<ForisForm
@ -118,5 +117,3 @@ export function validator(formData) {
});
return JSON.stringify(formErrors).match(/\[[{},?]+\]/) ? null : formErrors;
}
export default WiFiSettings;

View File

@ -1,20 +1,20 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2021 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import { render, fireEvent, waitFor } from "customTestRender";
import { render, fireEvent, wait } from "customTestRender";
import mockAxios from "jest-mock-axios";
import WebSockets from "webSockets/WebSockets";
import { WebSockets } from "webSockets/WebSockets";
import { mockJSONError } from "testUtils/network";
import { mockSetAlert } from "testUtils/alertContextMock";
import { ALERT_TYPES } from "../../../bootstrap/Alert";
import ResetWiFiSettings from "../ResetWiFiSettings";
import { ResetWiFiSettings } from "../ResetWiFiSettings";
describe("<ResetWiFiSettings/>", () => {
const webSockets = new WebSockets();
@ -35,7 +35,7 @@ describe("<ResetWiFiSettings/>", () => {
expect.anything()
);
mockAxios.mockResponse({ data: { foo: "bar" } });
await waitFor(() =>
await wait(() =>
expect(mockSetAlert).toBeCalledWith(
"Wi-Fi settings are set to defaults.",
ALERT_TYPES.SUCCESS
@ -46,7 +46,7 @@ describe("<ResetWiFiSettings/>", () => {
it("should display alert on open ports - failure", async () => {
fireEvent.click(getAllByText("Reset Wi-Fi Settings")[1]);
mockJSONError();
await waitFor(() =>
await wait(() =>
expect(mockSetAlert).toBeCalledWith(
"An error occurred during resetting Wi-Fi settings."
)

View File

@ -1,17 +1,16 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2021 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import diffSnapshot from "snapshot-diff";
import mockAxios from "jest-mock-axios";
import { fireEvent, render, waitFor } from "customTestRender";
import WebSockets from "webSockets/WebSockets";
import { fireEvent, render, wait } from "customTestRender";
import { WebSockets } from "webSockets/WebSockets";
import { mockJSONError } from "testUtils/network";
import {
@ -20,7 +19,7 @@ import {
twoDevices,
threeDevices,
} from "./__fixtures__/wifiSettings";
import WiFiSettings, { validator, byteCount } from "../WiFiSettings";
import { WiFiSettings, validator, byteCount } from "../WiFiSettings";
describe("<WiFiSettings/>", () => {
let firstRender;
@ -46,7 +45,7 @@ describe("<WiFiSettings/>", () => {
getByLabelText = renderRes.getByLabelText;
getByText = renderRes.getByText;
mockAxios.mockResponse({ data: wifiSettingsFixture() });
await waitFor(() => renderRes.getByText("Wi-Fi 1"));
await wait(() => renderRes.getByText("Wi-Fi 1"));
firstRender = renderRes.asFragment();
});
@ -61,7 +60,7 @@ describe("<WiFiSettings/>", () => {
);
const errorMessage = "An API error occurred.";
mockJSONError(errorMessage);
await waitFor(() => {
await wait(() => {
expect(getByText(errorMessage)).toBeTruthy();
});
});
@ -78,7 +77,7 @@ describe("<WiFiSettings/>", () => {
it("Snapshot 2.4 GHz", () => {
fireEvent.click(getByText("Wi-Fi 1"));
const enabledRender = asFragment();
fireEvent.click(getAllByText(/2.4/)[0]);
fireEvent.click(getAllByText("2.4")[0]);
expect(diffSnapshot(enabledRender, asFragment())).toMatchSnapshot();
});
@ -119,7 +118,7 @@ describe("<WiFiSettings/>", () => {
guest_wifi: { enabled: false },
hidden: false,
htmode: "HT80",
band: "5g",
hwmode: "11a",
id: 0,
password: "TestPass",
encryption: "WPA3",
@ -136,7 +135,7 @@ describe("<WiFiSettings/>", () => {
it("Post form: 2.4 GHz", () => {
fireEvent.click(getByText("Wi-Fi 1"));
fireEvent.click(getAllByText(/2.4/)[0]);
fireEvent.click(getAllByText("2.4")[0]);
fireEvent.click(getByText("Save"));
expect(mockAxios.post).toBeCalled();
@ -149,7 +148,7 @@ describe("<WiFiSettings/>", () => {
guest_wifi: { enabled: false },
hidden: false,
htmode: "VHT80",
band: "2g",
hwmode: "11g",
id: 0,
password: "TestPass",
encryption: "WPA3",
@ -182,12 +181,11 @@ describe("<WiFiSettings/>", () => {
guest_wifi: {
SSID: "TestGuestSSID",
enabled: true,
encryption: "WPA2",
password: "test_password",
},
hidden: false,
htmode: "HT80",
band: "5g",
hwmode: "11a",
id: 0,
password: "TestPass",
encryption: "WPA3",

View File

@ -77,7 +77,7 @@ export function wifiSettingsFixture() {
"VHT40",
"VHT80",
],
band: "2g",
hwmode: "11g",
},
{
available_channels: [
@ -215,7 +215,7 @@ export function wifiSettingsFixture() {
"VHT40",
"VHT80",
],
band: "5g",
hwmode: "11a",
},
],
channel: 60,
@ -223,12 +223,11 @@ export function wifiSettingsFixture() {
guest_wifi: {
SSID: "TestGuestSSID",
enabled: false,
encryption: "WPA2",
password: "",
},
hidden: false,
htmode: "HT80",
band: "5g",
hwmode: "11a",
id: 0,
password: "TestPass",
encryption: "WPA3",
@ -295,7 +294,7 @@ export function wifiSettingsFixture() {
},
],
available_htmodes: ["NOHT", "HT20", "HT40"],
band: "2g",
hwmode: "11g",
},
],
channel: 11,
@ -307,7 +306,7 @@ export function wifiSettingsFixture() {
},
hidden: false,
htmode: "HT40",
band: "2g",
hwmode: "11g",
id: 1,
password: "TestPass",
encryption: "WPA3",
@ -324,7 +323,7 @@ const oneDevice = {
guest_wifi: { enabled: false },
hidden: false,
htmode: "HT40",
band: "5g",
hwmode: "11a",
id: 0,
password: "TestPass",
encryption: "WPA3",
@ -341,7 +340,7 @@ const twoDevices = {
guest_wifi: { enabled: false },
hidden: false,
htmode: "HT40",
band: "5g",
hwmode: "11a",
id: 0,
password: "TestPass",
encryption: "WPA3",
@ -353,7 +352,7 @@ const twoDevices = {
guest_wifi: { enabled: false },
hidden: false,
htmode: "HT40",
band: "5g",
hwmode: "11a",
id: 1,
password: "TestPass",
encryption: "WPA3",
@ -370,7 +369,7 @@ const threeDevices = {
guest_wifi: { enabled: false },
hidden: false,
htmode: "HT40",
band: "5g",
hwmode: "11a",
id: 0,
password: "TestPass",
encryption: "WPA3",
@ -382,7 +381,7 @@ const threeDevices = {
guest_wifi: { enabled: false },
hidden: false,
htmode: "HT40",
band: "5g",
hwmode: "11a",
id: 1,
password: "TestPass",
encryption: "WPA3",
@ -394,7 +393,7 @@ const threeDevices = {
guest_wifi: { enabled: false },
hidden: false,
htmode: "HT40",
band: "5g",
hwmode: "11a",
id: 2,
password: "",
encryption: "WPA3",

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2022 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
@ -12,18 +12,15 @@ export const HTMODES = {
VHT20: _("802.11ac - 20 MHz wide channel"),
VHT40: _("802.11ac - 40 MHz wide channel"),
VHT80: _("802.11ac - 80 MHz wide channel"),
VHT80_80: _("802.11ac - 80+80 MHz wide channel"),
VHT160: _("802.11ac - 160 MHz wide channel"),
HE20: _("802.11ax - 20 MHz wide channel"),
HE40: _("802.11ax - 40 MHz wide channel"),
HE80: _("802.11ax - 80 MHz wide channel"),
HE80_80: _("802.11ax - 80+80 MHz wide channel"),
HE160: _("802.11ax - 160 MHz wide channel"),
};
export const BANDS = {
"2g": "2.4",
"5g": "5",
"6g": "6",
export const HWMODES = {
"11g": "2.4",
"11a": "5",
};
export const ENCRYPTIONMODES = {
WPA3: _("WPA3 only"),
@ -40,7 +37,7 @@ export const HELP_TEXTS = {
hidden: _(
"If set, network is not visible when scanning for available networks."
),
band: _(
hwmode: _(
"The 2.4 GHz band is more widely supported by clients, but tends to have more interference. The 5 GHz band is a newer standard and may not be supported by all your devices. It usually has less interference, but the signal does not carry so well indoors."
),
htmode: _(

View File

@ -1,86 +0,0 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import Button from "bootstrap/Button";
import { fireEvent, getByText, render, waitFor } from "customTestRender";
import mockAxios from "jest-mock-axios";
import { mockJSONError } from "testUtils/network";
import { mockSetAlert } from "testUtils/alertContextMock";
import ActionButtonWithModal from "../ActionButtonWithModal/ActionButtonWithModal";
describe("<ActionButtonWithModal/>", () => {
let componentContainer;
const ActionButton = (props) => (
<Button type="button" {...props}>
Action
</Button>
);
beforeEach(() => {
const { container } = render(
<>
<div id="modal-container" />
<div id="alert-container" />
<ActionButtonWithModal
actionTrigger={ActionButton}
actionUrl="/reforis/api/action"
modalTitle="Warning!"
modalMessage="Are you sure you want to perform this action?"
modalActionText="Confirm action"
modalActionProps={{ className: "btn-danger" }}
successMessage="Action request succeeded."
errorMessage="Action request failed."
/>
</>
);
componentContainer = container;
});
it("Render button.", () => {
expect(componentContainer).toMatchSnapshot();
});
it("Render modal.", () => {
fireEvent.click(getByText(componentContainer, "Action"));
expect(componentContainer).toMatchSnapshot();
});
it("Confirm action.", () => {
fireEvent.click(getByText(componentContainer, "Action"));
fireEvent.click(getByText(componentContainer, "Confirm action"));
expect(mockAxios.post).toHaveBeenCalledWith(
"/reforis/api/action",
undefined,
expect.anything()
);
});
it("Hold error.", async () => {
fireEvent.click(getByText(componentContainer, "Action"));
fireEvent.click(getByText(componentContainer, "Confirm action"));
mockJSONError();
await waitFor(() =>
expect(mockSetAlert).toBeCalledWith("Action request failed.")
);
});
it("Show success alert on successful action.", async () => {
fireEvent.click(getByText(componentContainer, "Action"));
fireEvent.click(getByText(componentContainer, "Confirm action"));
mockAxios.mockResponse({ status: 200 });
await waitFor(() =>
expect(mockSetAlert).toBeCalledWith(
"Action request succeeded.",
"success"
)
);
});
});

View File

@ -0,0 +1,63 @@
/*
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import {
fireEvent,
getByText,
queryByText,
render,
wait,
} from "customTestRender";
import mockAxios from "jest-mock-axios";
import { mockJSONError } from "testUtils/network";
import { mockSetAlert } from "testUtils/alertContextMock";
import { RebootButton } from "../RebootButton";
describe("<RebootButton/>", () => {
let componentContainer;
beforeEach(() => {
const { container } = render(
<>
<div id="modal-container" />
<RebootButton />
</>
);
componentContainer = container;
});
it("Render.", () => {
expect(componentContainer).toMatchSnapshot();
});
it("Render modal.", () => {
expect(queryByText(componentContainer, "Confirm reboot")).toBeNull();
fireEvent.click(getByText(componentContainer, "Reboot"));
expect(componentContainer).toMatchSnapshot();
});
it("Confirm reboot.", () => {
fireEvent.click(getByText(componentContainer, "Reboot"));
fireEvent.click(getByText(componentContainer, "Confirm reboot"));
expect(mockAxios.post).toHaveBeenCalledWith(
"/reforis/api/reboot",
undefined,
expect.anything()
);
});
it("Hold error.", async () => {
fireEvent.click(getByText(componentContainer, "Reboot"));
fireEvent.click(getByText(componentContainer, "Confirm reboot"));
mockJSONError();
await wait(() =>
expect(mockSetAlert).toBeCalledWith("Reboot request failed.")
);
});
});

View File

@ -1,99 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ActionButtonWithModal/> Render button. 1`] = `
<div>
<div
id="modal-container"
/>
<div
id="alert-container"
/>
<button
class="btn btn-primary d-inline-flex justify-content-center align-items-center"
type="button"
>
<span>
Action
</span>
</button>
</div>
`;
exports[`<ActionButtonWithModal/> Render modal. 1`] = `
<div>
<div
id="modal-container"
>
<div
aria-labelledby="modal-title"
aria-modal="true"
class="modal fade show"
role="dialog"
>
<div
class="modal-dialog modal-dialog-centered"
role="document"
>
<div
class="modal-content"
>
<div
class="modal-header"
>
<h1
class="modal-title fs-5"
>
Warning!
</h1>
<button
aria-label="Close"
class="btn-close"
type="button"
/>
</div>
<div
class="modal-body"
>
<p
class="mb-0"
>
Are you sure you want to perform this action?
</p>
</div>
<div
class="modal-footer"
>
<button
class="btn btn-secondary d-inline-flex justify-content-center align-items-center"
type="button"
>
<span>
Cancel
</span>
</button>
<button
class="btn btn-danger d-inline-flex justify-content-center align-items-center"
type="button"
>
<span>
Confirm action
</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div
id="alert-container"
/>
<button
class="btn btn-primary d-inline-flex justify-content-center align-items-center"
type="button"
>
<span>
Action
</span>
</button>
</div>
`;

View File

@ -0,0 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RebootButton/> Render modal. 1`] = `
<div>
<div
id="modal-container"
>
<div
class="modal fade show"
role="dialog"
>
<div
class="modal-dialog modal-dialog-centered"
role="document"
>
<div
class="modal-content"
>
<div
class="modal-header"
>
<h5
class="modal-title"
>
Warning!
</h5>
<button
class="close"
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
</div>
<div
class="modal-body"
>
<p>
Are you sure you want to restart the router?
</p>
</div>
<div
class="modal-footer"
>
<button
class="btn btn-primary "
type="button"
>
Cancel
</button>
<button
class="btn btn-danger"
type="button"
>
Confirm reboot
</button>
</div>
</div>
</div>
</div>
</div>
<button
class="btn btn-danger"
type="button"
>
Reboot
</button>
</div>
`;
exports[`<RebootButton/> Render. 1`] = `
<div>
<div
id="modal-container"
/>
<button
class="btn btn-danger"
type="button"
>
Reboot
</button>
</div>
`;

View File

@ -1,68 +0,0 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useContext, useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import { useAPIGet } from "../../api/hooks";
import { Spinner } from "../../bootstrap/Spinner";
import { ForisURLs } from "../../utils/forisUrls";
CustomizationContextProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]),
};
function CustomizationContextProvider({ children }) {
const { CustomizationContext } = window;
const [getCustomizationResponse, getCustomization] = useAPIGet(
ForisURLs.about
);
useEffect(() => {
getCustomization();
}, [getCustomization]);
const deviceDetails = useMemo(
() => getCustomizationResponse.data || {},
[getCustomizationResponse.data]
);
const isCustomized = useMemo(
() =>
!!(
deviceDetails.customization !== undefined &&
deviceDetails.customization === "shield"
),
[deviceDetails.customization]
);
const contextValue = useMemo(
() => ({ deviceDetails, isCustomized }),
[deviceDetails, isCustomized]
);
if (getCustomizationResponse.state !== "success") {
return <Spinner fullScreen />;
}
return (
<CustomizationContext.Provider value={contextValue}>
{children}
</CustomizationContext.Provider>
);
}
function useCustomizationContext() {
const { CustomizationContext } = window;
return useContext(CustomizationContext);
}
export { CustomizationContextProvider, useCustomizationContext };

View File

@ -1,3 +0,0 @@
It provides customization context to the children. `CustomizationContext` allows
using `useCustomizationContext` in components to check if the reForis UI is
customized or not for specific devices.

View File

@ -1,53 +0,0 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import { render, waitFor, getByText } from "customTestRender";
import mockAxios from "jest-mock-axios";
import {
useCustomizationContext,
CustomizationContextProvider,
} from "../CustomizationContext";
const CUSTOM = "Description / component for customized reForis (Shield)";
const ORIGINAL = "Description / component for original reForis (other devices)";
const CustomizationTest = () => {
const { isCustomized } = useCustomizationContext();
return <p>{isCustomized ? CUSTOM : ORIGINAL}</p>;
};
describe("CustomizationContext", () => {
let componentContainer;
beforeEach(() => {
const { container } = render(
<CustomizationContextProvider>
<CustomizationTest />
</CustomizationContextProvider>
);
componentContainer = container;
});
it("should render component without customization", async () => {
mockAxios.mockResponse({ data: {} });
await waitFor(() => getByText(componentContainer, ORIGINAL));
expect(componentContainer).toMatchSnapshot();
});
it("should render customized component", async () => {
mockAxios.mockResponse({ data: { customization: "shield" } });
await waitFor(() => getByText(componentContainer, CUSTOM));
expect(componentContainer).toMatchSnapshot();
});
});

View File

@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CustomizationContext should render component without customization 1`] = `
<div>
<p>
Description / component for original reForis (other devices)
</p>
</div>
`;
exports[`CustomizationContext should render customized component 1`] = `
<div>
<p>
Description / component for customized reForis (Shield)
</p>
</div>
`;

View File

@ -3,18 +3,17 @@
exports[`<SubmitButton/> Render load 1`] = `
<div>
<button
class="btn btn-primary col-12 col-md-3 col-lg-2 d-inline-flex justify-content-center align-items-center"
class="btn btn-primary col-sm-12 col-md-3 col-lg-2"
disabled=""
type="submit"
>
<span
aria-hidden="true"
class="spinner-border spinner-border-sm me-1"
class="spinner-border spinner-border-sm"
role="status"
/>
<span>
Load settings
</span>
Load settings
</button>
</div>
`;
@ -22,12 +21,10 @@ exports[`<SubmitButton/> Render load 1`] = `
exports[`<SubmitButton/> Render ready 1`] = `
<div>
<button
class="btn btn-primary col-12 col-md-3 col-lg-2 d-inline-flex justify-content-center align-items-center"
class="btn btn-primary col-sm-12 col-md-3 col-lg-2"
type="submit"
>
<span>
Save
</span>
Save
</button>
</div>
`;
@ -35,18 +32,17 @@ exports[`<SubmitButton/> Render ready 1`] = `
exports[`<SubmitButton/> Render saving 1`] = `
<div>
<button
class="btn btn-primary col-12 col-md-3 col-lg-2 d-inline-flex justify-content-center align-items-center"
class="btn btn-primary col-sm-12 col-md-3 col-lg-2"
disabled=""
type="submit"
>
<span
aria-hidden="true"
class="spinner-border spinner-border-sm me-1"
class="spinner-border spinner-border-sm"
role="status"
/>
<span>
Updating
</span>
Updating
</button>
</div>
`;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
@ -7,10 +7,10 @@
import React from "react";
import { act, fireEvent, render, waitFor } from "customTestRender";
import { act, fireEvent, render, waitForElement } from "customTestRender";
import mockAxios from "jest-mock-axios";
import WebSockets from "webSockets/WebSockets";
import ForisForm from "../components/ForisForm";
import { WebSockets } from "webSockets/WebSockets";
import { ForisForm } from "../components/ForisForm";
// It's possible to unittest each hooks via react-hooks-testing-library.
// But it's better and easier to test it by test components which uses this hooks.
@ -59,7 +59,7 @@ describe("useForm hook.", () => {
);
mockAxios.mockResponse({ field: "fetchedData" });
input = await waitFor(() => getByTestId("test-input"));
input = await waitForElement(() => getByTestId("test-input"));
form = container.firstChild.firstChild;
});

View File

@ -1,24 +1,24 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React, { useEffect } from "react";
import PropTypes from "prop-types";
import { Prompt } from "react-router-dom";
import { STATES as SUBMIT_BUTTON_STATES, SubmitButton } from "./SubmitButton";
import { useAPIPost } from "../../api/hooks";
import { API_STATE } from "../../api/utils";
import { ALERT_TYPES } from "../../bootstrap/Alert";
import { API_STATE } from "../../api/utils";
import { formFieldsSize } from "../../bootstrap/constants";
import { Spinner } from "../../bootstrap/Spinner";
import { useAlert } from "../../context/alertContext/AlertContext";
import ErrorMessage from "../../utils/ErrorMessage";
import { useAlert } from "../../alertContext/AlertContext";
import { useAPIPost } from "../../api/hooks";
import { useForisModule, useForm } from "../hooks";
import { STATES as SUBMIT_BUTTON_STATES, SubmitButton } from "./SubmitButton";
import { ErrorMessage } from "../../utils/ErrorMessage";
ForisForm.propTypes = {
/** Optional WebSocket object. See `scr/common/WebSockets.js`.
@ -89,7 +89,7 @@ ForisForm.defaultProps = {
* use exposed `ReactRouterDOM` object from `react-router-dom` library which is exposed by reForis.
* See README for more information.
* */
function ForisForm({
export function ForisForm({
ws,
forisConfig,
prepData,
@ -131,16 +131,16 @@ function ForisForm({
return <Spinner />;
}
const onSubmitHandler = (event) => {
function onSubmitHandler(event) {
event.preventDefault();
resetFormData();
dismissAlert();
const copiedFormData = JSON.parse(JSON.stringify(formState.data));
const preparedData = prepDataToSubmit(copiedFormData);
post({ data: preparedData });
};
}
const getSubmitButtonState = () => {
function getSubmitButtonState() {
if (postState.state === API_STATE.SENDING) {
return SUBMIT_BUTTON_STATES.SAVING;
}
@ -148,7 +148,7 @@ function ForisForm({
return SUBMIT_BUTTON_STATES.LOAD;
}
return SUBMIT_BUTTON_STATES.READY;
};
}
const formIsDisabled =
disabled ||
@ -174,7 +174,7 @@ function ForisForm({
)
: onSubmitHandler;
const getMessageOnLeavingPage = () => {
function getMessageOnLeavingPage() {
if (
JSON.stringify(formState.data) ===
JSON.stringify(formState.initialData)
@ -183,14 +183,14 @@ function ForisForm({
return _(
"Changes you made may not be saved. Are you sure you want to leave?"
);
};
}
return (
<div className={formFieldsSize}>
<Prompt message={getMessageOnLeavingPage} />
<form onSubmit={onSubmit} ref={formReference}>
{childrenWithFormProps}
<div className="text-end">
<div className="text-right">
<SubmitButton
state={getSubmitButtonState()}
disabled={submitButtonIsDisabled}
@ -200,5 +200,3 @@ function ForisForm({
</div>
);
}
export default ForisForm;

View File

@ -1,15 +1,14 @@
/*
* Copyright (C) 2019-2025 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
import Button from "../../bootstrap/Button";
import { Button } from "../../bootstrap/Button";
export const STATES = {
READY: 1,
@ -20,25 +19,22 @@ export const STATES = {
SubmitButton.propTypes = {
disabled: PropTypes.bool,
state: PropTypes.oneOf(Object.keys(STATES).map((key) => STATES[key])),
label: PropTypes.string,
};
export function SubmitButton({ disabled, state, label, ...props }) {
export function SubmitButton({ disabled, state, ...props }) {
const disableSubmitButton = disabled || state !== STATES.READY;
const loadingSubmitButton = state !== STATES.READY;
let labelSubmitButton = label;
if (!labelSubmitButton) {
switch (state) {
case STATES.SAVING:
labelSubmitButton = _("Updating");
break;
case STATES.LOAD:
labelSubmitButton = _("Load settings");
break;
default:
labelSubmitButton = _("Save");
}
let labelSubmitButton;
switch (state) {
case STATES.SAVING:
labelSubmitButton = _("Updating");
break;
case STATES.LOAD:
labelSubmitButton = _("Load settings");
break;
default:
labelSubmitButton = _("Save");
}
return (

View File

@ -1,16 +1,15 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import { useCallback, useEffect, useReducer } from "react";
import update from "immutability-helper";
import { useAPIGet } from "../api/hooks";
import useWSForisModule from "../webSockets/hooks";
import { useWSForisModule } from "../webSockets/hooks";
const FORM_ACTIONS = {
updateValue: 1,

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2021 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
@ -17,35 +17,32 @@ export {
export { API_STATE } from "./api/utils";
// Bootstrap
export { default as Alert, ALERT_TYPES } from "./bootstrap/Alert";
export { default as Button } from "./bootstrap/Button";
export { default as CheckBox } from "./bootstrap/CheckBox";
export { default as CopyInput } from "./bootstrap/CopyInput";
export { default as DownloadButton } from "./bootstrap/DownloadButton";
export { default as DataTimeInput } from "./bootstrap/DataTimeInput";
export { default as EmailInput } from "./bootstrap/EmailInput";
export { default as FileInput } from "./bootstrap/FileInput";
export { default as Input } from "./bootstrap/Input";
export { default as NumberInput } from "./bootstrap/NumberInput";
export { default as PasswordInput } from "./bootstrap/PasswordInput";
export { default as Radio } from "./bootstrap/Radio";
export { default as RadioSet } from "./bootstrap/RadioSet";
export { default as Select } from "./bootstrap/Select";
export { default as TextInput } from "./bootstrap/TextInput";
export { Alert, ALERT_TYPES } from "./bootstrap/Alert";
export { Button } from "./bootstrap/Button";
export { CheckBox } from "./bootstrap/CheckBox";
export { CopyInput } from "./bootstrap/CopyInput";
export { DownloadButton } from "./bootstrap/DownloadButton";
export { DataTimeInput } from "./bootstrap/DataTimeInput";
export { EmailInput } from "./bootstrap/EmailInput";
export { FileInput } from "./bootstrap/FileInput";
export { Input } from "./bootstrap/Input";
export { NumberInput } from "./bootstrap/NumberInput";
export { PasswordInput } from "./bootstrap/PasswordInput";
export { Radio, RadioSet } from "./bootstrap/RadioSet";
export { Select } from "./bootstrap/Select";
export { TextInput } from "./bootstrap/TextInput";
export { formFieldsSize, buttonFormFieldsSize } from "./bootstrap/constants";
export { default as Switch } from "./bootstrap/Switch";
export { default as ThreeDotsMenu } from "./bootstrap/ThreeDotsMenu";
export { Switch } from "./bootstrap/Switch";
export { Spinner, SpinnerElement } from "./bootstrap/Spinner";
export { Modal, ModalBody, ModalFooter, ModalHeader } from "./bootstrap/Modal";
// Common
export { default as ActionButtonWithModal } from "./common/ActionButtonWithModal/ActionButtonWithModal";
export { default as WiFiSettings } from "./common/WiFiSettings/WiFiSettings";
export { default as ResetWiFiSettings } from "./common/WiFiSettings/ResetWiFiSettings";
export { default as RichTable } from "./common/RichTable/RichTable";
export { RebootButton } from "./common/RebootButton";
export { WiFiSettings } from "./common/WiFiSettings/WiFiSettings";
export { ResetWiFiSettings } from "./common/WiFiSettings/ResetWiFiSettings";
// Form
export { default as ForisForm } from "./form/components/ForisForm";
export { ForisForm } from "./form/components/ForisForm";
export {
SubmitButton,
STATES as SUBMIT_BUTTON_STATES,
@ -53,11 +50,11 @@ export {
export { useForisModule, useForm } from "./form/hooks";
// WebSockets
export { default as useWSForisModule } from "./webSockets/hooks";
export { default as WebSockets } from "./webSockets/WebSockets";
export { useWSForisModule } from "./webSockets/hooks";
export { WebSockets } from "./webSockets/WebSockets";
// Utils
export { default as Portal } from "./utils/Portal";
export { Portal } from "./utils/Portal";
export {
undefinedIfEmpty,
withoutUndefinedKeys,
@ -71,11 +68,11 @@ export {
withError,
withErrorMessage,
} from "./utils/conditionalHOCs";
export { default as ErrorMessage } from "./utils/ErrorMessage";
export { ErrorMessage } from "./utils/ErrorMessage";
export { useClickOutside } from "./utils/hooks";
export { default as toLocaleDateString } from "./utils/datetime";
export { default as displayCard } from "./utils/displayCard";
export { default as isPluginInstalled } from "./utils/isPluginInstalled";
export { toLocaleDateString } from "./utils/datetime";
export { displayCard } from "./utils/displayCard";
export { isPluginInstalled } from "./utils/isPluginInstalled";
// Foris URL
export { ForisURLs, REFORIS_URL_PREFIX } from "./utils/forisUrls";
@ -93,13 +90,4 @@ export {
} from "./utils/validations";
// Alert context
export {
AlertContextProvider,
useAlert,
} from "./context/alertContext/AlertContext";
// Customization context
export {
CustomizationContextProvider,
useCustomizationContext,
} from "./context/customizationContext/CustomizationContext";
export { AlertContextProvider, useAlert } from "./alertContext/AlertContext";

View File

@ -14,7 +14,6 @@ import { render } from "@testing-library/react";
import PropTypes from "prop-types";
import { AlertContextMock } from "./alertContextMock";
import { CustomizationContextMock } from "./customizationContextMock";
Wrapper.propTypes = {
children: PropTypes.oneOfType([
@ -25,13 +24,11 @@ Wrapper.propTypes = {
function Wrapper({ children }) {
return (
<CustomizationContextMock>
<AlertContextMock>
<StaticRouter>
<UIDReset>{children}</UIDReset>
</StaticRouter>
</AlertContextMock>
</CustomizationContextMock>
<AlertContextMock>
<StaticRouter>
<UIDReset>{children}</UIDReset>
</StaticRouter>
</AlertContextMock>
);
}

View File

@ -1,34 +0,0 @@
/*
* Copyright (C) 2019-2022 CZ.NIC z.s.p.o. (https://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
window.CustomizationContext = React.createContext();
const deviceDetails = {
kernel: "5.x.x",
model: "Turris Omnia",
os_branch: {
mode: "branch",
value: "hbs",
},
os_version: "6.x.x",
reforis_version: "1.x.x",
serial: 123456789,
};
const isCustomized = false;
function CustomizationContextMock({ children }) {
return (
<CustomizationContext.Provider value={{ deviceDetails, isCustomized }}>
{children}
</CustomizationContext.Provider>
);
}
export { CustomizationContextMock };

View File

@ -1,10 +1,10 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import mockAxios from "jest-mock-axios";
import moment from "moment-timezone";
import "./mockGlobals";
@ -26,8 +26,3 @@ jest.doMock("moment", () => {
return moment;
});
Date.now = jest.fn(() => new Date(Date.UTC(2019, 1, 1, 12, 13, 14)).valueOf());
// Mock Font Awesome v6 library
jest.mock("@fortawesome/react-fontawesome", () => ({
FontAwesomeIcon: () => <i className="fa" />,
}));

View File

@ -1,12 +1,11 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import React from "react";
import PropTypes from "prop-types";
ErrorMessage.propTypes = {
@ -17,8 +16,6 @@ ErrorMessage.defaultProps = {
message: _("An error occurred while fetching data."),
};
function ErrorMessage({ message }) {
export function ErrorMessage({ message }) {
return <p className="text-center text-danger">{message}</p>;
}
export default ErrorMessage;

View File

@ -1,22 +1,14 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
import PropTypes from "prop-types";
import ReactDOM from "react-dom";
Portal.propTypes = {
containerId: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
function Portal({ containerId, children }) {
export function Portal({ containerId, children }) {
const container = document.getElementById(containerId);
if (container) return ReactDOM.createPortal(children, container);
return null;
}
export default Portal;

View File

@ -5,7 +5,7 @@
* See /LICENSE for more information.
*/
import toLocaleDateString from "../datetime";
import { toLocaleDateString } from "../datetime";
describe("toLocaleDateString", () => {
it("should work with different locale", () => {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
@ -7,23 +7,16 @@
import React from "react";
import ErrorMessage from "./ErrorMessage";
import { API_STATE } from "../api/utils";
import { Spinner } from "../bootstrap/Spinner";
import { API_STATE } from "../api/utils";
import { ErrorMessage } from "./ErrorMessage";
function withEither(conditionalFn, Either) {
return (Component) => {
function WithEither(props) {
if (conditionalFn(props)) {
return <Either {...props} />;
}
return <Component {...props} />;
return (Component) => (props) => {
if (conditionalFn(props)) {
return <Either {...props} />;
}
// Setting displayName for better debugging
WithEither.displayName = `WithEither(${Component.displayName || Component.name || "Component"})`;
return WithEither;
return <Component {...props} />;
};
}

View File

@ -1,8 +1,9 @@
import moment from "moment";
function toLocaleDateString(date, { inputFormat, outputFormat = "LLL" } = {}) {
export function toLocaleDateString(
date,
{ inputFormat, outputFormat = "LLL" } = {}
) {
const parsedDate = inputFormat ? moment(date, inputFormat) : moment(date);
return parsedDate.locale(ForisTranslations.locale).format(outputFormat);
}
export default toLocaleDateString;

View File

@ -1,11 +1,11 @@
/*
* Copyright (C) 2020-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2020 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
*/
function displayCard({ package_lists: packages }, cardName) {
export function displayCard({ package_lists: packages }, cardName) {
const enabledPackagesNames = [];
packages
.filter((item) => item.enabled)
@ -21,5 +21,3 @@ function displayCard({ package_lists: packages }, cardName) {
});
return enabledPackagesNames.includes(cardName);
}
export default displayCard;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019-2021 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
@ -9,10 +9,8 @@ export const REFORIS_URL_PREFIX = "/reforis";
export const REFORIS_API_URL_PREFIX = `${REFORIS_URL_PREFIX}/api`;
export const ForisURLs = {
// turris-auth
login: `/login?${REFORIS_URL_PREFIX}/`,
logout: `/logout`,
extendSession: `/extend-session`,
static: `${REFORIS_URL_PREFIX}/static/reforis`,
wifi: `${REFORIS_URL_PREFIX}/network-settings/wifi`,
@ -27,19 +25,10 @@ export const ForisURLs = {
storage: `${REFORIS_URL_PREFIX}/storage`,
sentinelAgreement: `${REFORIS_URL_PREFIX}/sentinel/agreement`,
// URLs without prefix for Overview page
openvpnClientSettings: "/openvpn/client-settings",
packageManagementPackages: "/package-management/packages",
packageManagementUpdateSettings: "/package-management/update-settings",
sentinelLicenseAgreement: "/sentinel/agreement",
librespeedSpeedTest: "/librespeed/speed-test",
// Notifications links are used with <Link/> inside Router, thus url subdir is not required.
overview: "/overview",
notifications: "/overview#notifications",
notificationsSettings: "/administration/notifications-settings",
wifiSettings: "/network-settings/wifi",
lanSettings: "/network-settings/lan",
approveUpdates: "/package-management/updates",
languages: "/package-management/languages",
@ -47,6 +36,5 @@ export const ForisURLs = {
luci: "/cgi-bin/luci",
// API
about: `${REFORIS_API_URL_PREFIX}/about`,
reboot: `${REFORIS_API_URL_PREFIX}/reboot`,
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2019-2024 CZ.NIC z.s.p.o. (https://www.nic.cz/)
* Copyright (C) 2019 CZ.NIC z.s.p.o. (http://www.nic.cz/)
*
* This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information.
@ -40,40 +40,3 @@ export function useClickOutside(element, callback) {
};
});
}
/* Trap focus inside element. */
export function useFocusTrap(elementRef, condition = true) {
useEffect(() => {
if (!condition) {
return;
}
const currentElement = elementRef.current;
const focusableElements = currentElement.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTab = (event) => {
if (event.key === "Tab") {
if (event.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
} else if (document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
};
currentElement.addEventListener("keydown", handleTab);
firstElement.focus();
return () => {
currentElement.removeEventListener("keydown", handleTab);
};
}, [elementRef, condition]);
}

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