From ca699849bab799fa41caa5a92708a119842a1d9c Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Wed, 23 Aug 2023 16:11:18 +0800 Subject: [PATCH 01/12] console: Display location from gateway status messages in the map --- CHANGELOG.md | 2 + .../console/components/gateway-map/index.js | 14 ++-- .../console/components/location-form/index.js | 15 ++-- pkg/webui/console/lib/location-to-markers.js | 76 +++++++++++++------ pkg/webui/console/store/actions/gateways.js | 4 + .../store/middleware/logics/gateways.js | 17 +++++ pkg/webui/console/store/reducers/gateways.js | 25 ++++++ pkg/webui/lib/prop-types.js | 40 +++++----- pkg/webui/lib/shared-messages.js | 12 ++- pkg/webui/locales/en.json | 11 ++- 10 files changed, 156 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2bacb618..cc47b347cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ For details about compatibility between different releases, see the **Commitment ### Added +- Locations retrieved from gateway status messages are now be displayed in the gateway map in the Console, even when they are not received through a secure connection. + ### Changed ### Deprecated diff --git a/pkg/webui/console/components/gateway-map/index.js b/pkg/webui/console/components/gateway-map/index.js index fefa8aeac0..9ae882531e 100644 --- a/pkg/webui/console/components/gateway-map/index.js +++ b/pkg/webui/console/components/gateway-map/index.js @@ -18,18 +18,14 @@ import MapWidget from '@ttn-lw/components/map/widget' import PropTypes from '@ttn-lw/lib/prop-types' +import locationToMarkers from '@console/lib/location-to-markers' + const GatewayMap = ({ gateway }) => { const { gateway_id } = gateway.ids - const markers = - gateway.antennas && gateway.antennas.length > 0 && gateway.antennas[0].location - ? gateway.antennas.map(location => ({ - position: { - latitude: location.location.latitude || 0, - longitude: location.location.longitude || 0, - }, - })) - : [] + const markers = gateway.antennas + ? locationToMarkers(gateway.antennas.map(antenna => antenna.location)) + : [] return ( const defaultLocation = [38.43745529233546, -5.089416503906251] +const emptyLocation = { + latitude: undefined, + longitude: undefined, + altitude: undefined, +} + class LocationForm extends Component { static propTypes = { additionalMarkers: PropTypes.markers, @@ -108,11 +114,7 @@ class LocationForm extends Component { additionalMarkers: [], children: null, disabledInfo: undefined, - initialValues: { - latitude: undefined, - longitude: undefined, - altitude: undefined, - }, + initialValues: emptyLocation, validationSchema, updatesDisabled: false, noLocationSetInfo: m.noLocationSetInfo, @@ -240,7 +242,7 @@ class LocationForm extends Component { try { await onDelete(deleteAll) - this.form.current.resetForm() + this.form.current.resetForm({ values: emptyLocation }) this.setState({ latitude: undefined, longitude: undefined }) toast({ title: entityId, @@ -276,7 +278,6 @@ class LocationForm extends Component { return (
- isPlainObject(locations) - ? Object.keys(locations).map(key => ({ - position: { - latitude: locations[key].latitude || 0, - longitude: locations[key].longitude || 0, - }, - accuracy: locations[key].accuracy, - children: ( - - ), - })) - : [] +const sourceMessages = { + SOURCE_GPS: sharedMessages.locationSourceGps, + SOURCE_REGISTRY: sharedMessages.locationSourceRegistry, + SOURCE_UNKNOWN: sharedMessages.locationSourceUnknown, + SOURCE_IP_GEOLOCATION: sharedMessages.locationSourceIpGeolocation, + SOURCE_WIFI_RSSI_GEOLOCATION: sharedMessages.locationSourceWifiRssi, + SOURCE_BT_RSSI_GEOLOCATION: sharedMessages.locationSourceBtRssi, + SOURCE_LORA_RSSI_GEOLOCATION: sharedMessages.locationSourceLoraRssi, + SOURCE_LORA_TDOA_GEOLOCATION: sharedMessages.locationSourceLoraTdoa, + SOURCE_COMBINED_GEOLOCATION: sharedMessages.locationSourceCombined, +} + +const createLocationObject = (location, key) => ({ + position: { + latitude: location.latitude || 0, + longitude: location.longitude || 0, + }, + accuracy: location.accuracy, + children: ( + + +
+ +
+ Long: {location.longitude} / Lat: {location.latitude} +
+ ), +}) + +export default locations => { + if (isPlainObject(locations)) { + return Object.keys(locations).map(key => createLocationObject(locations[key], key)) + } + + if (isArray(locations)) { + return locations + .filter(l => Boolean(l) && isPlainObject(l) && !isEmpty(l)) + .map(location => createLocationObject(location)) + } + + return [] +} diff --git a/pkg/webui/console/store/actions/gateways.js b/pkg/webui/console/store/actions/gateways.js index a4b8b936ec..20d292cef9 100644 --- a/pkg/webui/console/store/actions/gateways.js +++ b/pkg/webui/console/store/actions/gateways.js @@ -78,6 +78,10 @@ export const [ (id, patch, selector) => ({ selector }), ) +export const UPDATE_GTW_LOCATION_BASE = 'UPDATE_GTW_LOCATION_BASE' +export const [{ success: UPDATE_GTW_LOCATION_SUCCESS }, { success: updateGatewayLocationSuccess }] = + createRequestActions(UPDATE_GTW_LOCATION_BASE) + export const DELETE_GTW_BASE = createPaginationDeleteBaseActionType(SHARED_NAME) export const [ { request: DELETE_GTW, success: DELETE_GTW_SUCCESS, failure: DELETE_GTW_FAILURE }, diff --git a/pkg/webui/console/store/middleware/logics/gateways.js b/pkg/webui/console/store/middleware/logics/gateways.js index 9a827a4da1..2ef5bff901 100644 --- a/pkg/webui/console/store/middleware/logics/gateways.js +++ b/pkg/webui/console/store/middleware/logics/gateways.js @@ -248,6 +248,22 @@ const updateGatewayStatisticsLogic = createRequestLogic({ }, }) +const getGatewayEventLocationLogic = createLogic({ + type: gateways.GET_GTW_EVENT_MESSAGE_SUCCESS, + validate: ({ action }, allow, reject) => { + if (action.event.name !== 'gs.status.receive' || !action?.event?.data?.antenna_locations) { + reject(action) + } else { + allow(action) + } + }, + process: async ({ action }, dispatch, done) => { + dispatch(gateways.updateGatewayLocationSuccess(action)) + + done() + }, +}) + export default [ createGatewayLogic, getGatewayLogic, @@ -258,5 +274,6 @@ export default [ getGatewaysRightsLogic, startGatewayStatisticsLogic, updateGatewayStatisticsLogic, + getGatewayEventLocationLogic, ...createEventsConnectLogics(gateways.SHARED_NAME, 'gateways', tts.Gateways.openStream), ] diff --git a/pkg/webui/console/store/reducers/gateways.js b/pkg/webui/console/store/reducers/gateways.js index ebf6e889c6..9fec9d1c19 100644 --- a/pkg/webui/console/store/reducers/gateways.js +++ b/pkg/webui/console/store/reducers/gateways.js @@ -18,6 +18,7 @@ import { GET_GTW, GET_GTW_SUCCESS, UPDATE_GTW_SUCCESS, + UPDATE_GTW_LOCATION_SUCCESS, DELETE_GTW_SUCCESS, GET_GTWS_LIST_SUCCESS, UPDATE_GTW_STATS, @@ -90,6 +91,30 @@ const gateways = (state = defaultState, action) => { [id]: gateway(state.entities[id], payload), }, } + case UPDATE_GTW_LOCATION_SUCCESS: { + const { id } = payload + const antennaLocations = payload.event.data.antenna_locations + + const composedLocations = antennaLocations.map(antennaLocation => ({ + location: { + ...antennaLocation, + // Locations from status messages can currently not be trusted + // in terms of integrity since they are not sent over a secure connection. + trusted: false, + }, + })) + + return { + ...state, + entities: { + ...state.entities, + [id]: { + ...state.entities[id], + antennas: composedLocations, + }, + }, + } + } case DELETE_GTW_SUCCESS: const { [payload.id]: deleted, ...rest } = state.entities diff --git a/pkg/webui/lib/prop-types.js b/pkg/webui/lib/prop-types.js index 9b3886a685..86a0e3f94a 100644 --- a/pkg/webui/lib/prop-types.js +++ b/pkg/webui/lib/prop-types.js @@ -72,6 +72,25 @@ PropTypes.inputWidth = PropTypes.oneOf(['xxs', 'xs', 's', 'm', 'l', 'full']) PropTypes.onlineStatus = PropTypes.oneOf(Object.values(ONLINE_STATUS)) // Entities and entity-related prop-types. +// +PropTypes.entityLocation = PropTypes.shape({ + latitude: PropTypes.number, + longitude: PropTypes.number, + altitude: PropTypes.number, + source: PropTypes.string, +}) + +PropTypes.entityLocations = PropTypes.shape({ + user: PropTypes.entityLocation, + 'frm-payload': PropTypes.entityLocation, + 'lora-cloud-device-management-v1-gnss': PropTypes.entityLocation, + 'lora-cloud-device-management-v1-wifi': PropTypes.entityLocation, + 'lora-cloud-device-management-v1-unknown': PropTypes.entityLocation, + 'lora-cloud-geolocation-v3-gnss': PropTypes.entityLocation, + 'lora-cloud-geolocation-v3-rssi': PropTypes.entityLocation, + 'lora-cloud-geolocation-v3-tdoa': PropTypes.entityLocation, + 'lora-cloud-geolocation-v3-rssitdoacombined': PropTypes.entityLocation, +}) PropTypes.event = PropTypes.shape({ name: PropTypes.string.isRequired, @@ -101,7 +120,7 @@ PropTypes.location = PropTypes.shape({ PropTypes.gateway = PropTypes.shape({ antennas: PropTypes.arrayOf( PropTypes.shape({ - location: PropTypes.location, + location: PropTypes.entityLocation, }), ), ids: PropTypes.shape({ @@ -205,25 +224,6 @@ PropTypes.env = PropTypes.shape({ }).isRequired, }) -PropTypes.entityLocation = PropTypes.shape({ - latitude: PropTypes.number, - longitude: PropTypes.number, - altitude: PropTypes.number, - source: PropTypes.string, -}) - -PropTypes.entityLocations = PropTypes.shape({ - user: PropTypes.entityLocation, - 'frm-payload': PropTypes.entityLocation, - 'lora-cloud-device-management-v1-gnss': PropTypes.entityLocation, - 'lora-cloud-device-management-v1-wifi': PropTypes.entityLocation, - 'lora-cloud-device-management-v1-unknown': PropTypes.entityLocation, - 'lora-cloud-geolocation-v3-gnss': PropTypes.entityLocation, - 'lora-cloud-geolocation-v3-rssi': PropTypes.entityLocation, - 'lora-cloud-geolocation-v3-tdoa': PropTypes.entityLocation, - 'lora-cloud-geolocation-v3-rssitdoacombined': PropTypes.entityLocation, -}) - PropTypes.device = PropTypes.shape({ ids: PropTypes.shape({ device_id: PropTypes.string.isRequired, diff --git a/pkg/webui/lib/shared-messages.js b/pkg/webui/lib/shared-messages.js index c9bda68d0e..ef8e915b76 100644 --- a/pkg/webui/lib/shared-messages.js +++ b/pkg/webui/lib/shared-messages.js @@ -266,10 +266,20 @@ export default defineMessages({ locationDescription: 'When set to public, the gateway location may be visible to other users of the network', locationMarkerDescriptionNonUser: - 'This location has been set automatically from incoming messages of this device', + 'This location has been set automatically from incoming (status) messages', locationMarkerDescriptionUser: 'This location has been set manually (e.g. by using the "Location"-tab)', + locationMarkerDescriptionUntrusted: + 'This location was determined via an untrusted status message and may be inaccurate', locationSolved: 'Location solved', + locationSourceGps: 'GPS-based location', + locationSourceRegistry: 'Manually set location', + locationSourceIpGeolocation: 'IP-based geolocation', + locationSourceWifiRssi: 'Wifi RSSI geolocation', + locationSourceBtRssi: 'Bluetooth RSSI geolocation', + locationSourceLoraRssi: 'LoRa RSSI geolocation', + locationSourceLoraTdoa: 'LoRa TDOA geolocation', + locationSourceCombined: 'Combined geolocation', login: 'Login', logout: 'Logout', longitude: 'Longitude', diff --git a/pkg/webui/locales/en.json b/pkg/webui/locales/en.json index 64df070270..7d5fc3a4c5 100644 --- a/pkg/webui/locales/en.json +++ b/pkg/webui/locales/en.json @@ -1359,9 +1359,18 @@ "lib.shared-messages.liveData": "Live data", "lib.shared-messages.location": "Location", "lib.shared-messages.locationDescription": "When set to public, the gateway location may be visible to other users of the network", - "lib.shared-messages.locationMarkerDescriptionNonUser": "This location has been set automatically from incoming messages of this device", + "lib.shared-messages.locationMarkerDescriptionNonUser": "This location has been set automatically from incoming (status) messages", "lib.shared-messages.locationMarkerDescriptionUser": "This location has been set manually (e.g. by using the \"Location\"-tab)", + "lib.shared-messages.locationMarkerDescriptionUntrusted": "This location was determined via an untrusted status message and may be inaccurate", "lib.shared-messages.locationSolved": "Location solved", + "lib.shared-messages.locationSourceGps": "GPS-based location", + "lib.shared-messages.locationSourceRegistry": "Manually set location", + "lib.shared-messages.locationSourceIpGeolocation": "IP-based geolocation", + "lib.shared-messages.locationSourceWifiRssi": "Wifi RSSI geolocation", + "lib.shared-messages.locationSourceBtRssi": "Bluetooth RSSI geolocation", + "lib.shared-messages.locationSourceLoraRssi": "LoRa RSSI geolocation", + "lib.shared-messages.locationSourceLoraTdoa": "LoRa TDOA geolocation", + "lib.shared-messages.locationSourceCombined": "Combined geolocation", "lib.shared-messages.login": "Login", "lib.shared-messages.logout": "Logout", "lib.shared-messages.longitude": "Longitude", From 1a3882336ee203058d50619180f420d26e4613b1 Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Mon, 28 Aug 2023 14:30:45 +0200 Subject: [PATCH 02/12] console: Refactor tab --- pkg/webui/components/tabs/tab/index.js | 158 ++++++++++++------------- 1 file changed, 76 insertions(+), 82 deletions(-) diff --git a/pkg/webui/components/tabs/tab/index.js b/pkg/webui/components/tabs/tab/index.js index 8113efc970..16d45b2bae 100644 --- a/pkg/webui/components/tabs/tab/index.js +++ b/pkg/webui/components/tabs/tab/index.js @@ -12,104 +12,98 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react' +import React, { useCallback } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import bind from 'autobind-decorator' import { NavLink } from 'react-router-dom' import style from './tab.styl' -class Tab extends React.PureComponent { - static propTypes = { - /** A flag specifying whether the tab is active. */ - active: PropTypes.bool, - children: PropTypes.node, - className: PropTypes.string, - /** A flag specifying whether the tab is disabled. */ - disabled: PropTypes.bool, - exact: PropTypes.bool, - link: PropTypes.string, - /** The name of the tab. */ - name: PropTypes.string.isRequired, - narrow: PropTypes.bool, - /** - * A click handler to be called when the selected tab changes. Passes the - * name of the new active tab as an argument. - */ - onClick: PropTypes.func, - } - - static defaultProps = { - children: undefined, - className: undefined, - link: undefined, - onClick: () => null, - active: false, - disabled: false, - narrow: false, - exact: true, - } - - @bind - handleClick() { - const { onClick, name, disabled } = this.props +const Tab = props => { + const { + className, + onClick, + name, + active = false, + disabled = false, + narrow, + children, + link, + exact = true, + ...rest + } = props + const handleClick = useCallback(() => { if (!disabled) { onClick(name) } - } + }, [disabled, name, onClick]) - render() { - const { - className, - onClick, - name, - active = false, - disabled = false, - narrow, - children, - link, - exact = true, - ...rest - } = this.props + const tabItemClassNames = classnames(className, style.tabItem, { + [style.tabItemNarrow]: narrow, + [style.tabItemActive]: !disabled && active, + [style.tabItemDefault]: !disabled && !active, + [style.tabItemDisabled]: disabled, + }) - const tabItemClassNames = classnames(className, style.tabItem, { - [style.tabItemNarrow]: narrow, - [style.tabItemActive]: !disabled && active, - [style.tabItemDefault]: !disabled && !active, - [style.tabItemDisabled]: disabled, - }) + // There is no support for disabled on anchors in html and hence in + // `react-router`. So, do not render the link component if the tab is + // disabled, but render regular tab item instead. + const canRenderLink = link && !disabled - // There is no support for disabled on anchors in html and hence in - // `react-router`. So, do not render the link component if the tab is - // disabled, but render regular tab item instead. - const canRenderLink = link && !disabled + const Component = canRenderLink ? NavLink : 'span' + const componentProps = { + role: 'button', + className: tabItemClassNames, + children, + } - const Component = canRenderLink ? NavLink : 'span' - const props = { - role: 'button', - className: tabItemClassNames, - children, - } + if (canRenderLink) { + componentProps.end = exact + componentProps.to = link + componentProps.className = ({ isActive }) => + classnames(tabItemClassNames, style.tabItem, { + [style.tabItemActive]: !disabled && isActive, + }) + } else { + componentProps.onClick = handleClick + } - if (canRenderLink) { - props.end = exact - props.to = link - props.className = ({ isActive }) => - classnames(tabItemClassNames, style.tabItem, { - [style.tabItemActive]: !disabled && isActive, - }) - } else { - props.onClick = this.handleClick - } + return ( +
  • + +
  • + ) +} - return ( -
  • - -
  • - ) - } +Tab.propTypes = { + /** A flag specifying whether the tab is active. */ + active: PropTypes.bool, + children: PropTypes.node, + className: PropTypes.string, + /** A flag specifying whether the tab is disabled. */ + disabled: PropTypes.bool, + exact: PropTypes.bool, + link: PropTypes.string, + /** The name of the tab. */ + name: PropTypes.string.isRequired, + narrow: PropTypes.bool, + /** + * A click handler to be called when the selected tab changes. Passes the + * name of the new active tab as an argument. + */ + onClick: PropTypes.func, +} + +Tab.defaultProps = { + children: undefined, + className: undefined, + link: undefined, + onClick: () => null, + active: false, + disabled: false, + narrow: false, + exact: true, } export default Tab From e4d4e9cfb1af09b8bcc104ab57696c2f6d714411 Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Mon, 28 Aug 2023 14:55:52 +0200 Subject: [PATCH 03/12] console: Refactor pagination --- pkg/webui/components/pagination/index.js | 163 +++++++++++------------ 1 file changed, 80 insertions(+), 83 deletions(-) diff --git a/pkg/webui/components/pagination/index.js b/pkg/webui/components/pagination/index.js index 0ecd0f3de9..04790fdbb5 100644 --- a/pkg/webui/components/pagination/index.js +++ b/pkg/webui/components/pagination/index.js @@ -12,103 +12,100 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react' +import React, { useCallback } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import bind from 'autobind-decorator' import Paginate from 'react-paginate' import Icon from '@ttn-lw/components/icon' import style from './pagination.styl' -class Pagination extends React.PureComponent { - static propTypes = { - className: PropTypes.string, - /** Page to be displayed immediately. */ - forcePage: PropTypes.number, - /** A flag indicating whether the pagination should be hidden when there is - * only one page. - */ - hideIfOnlyOnePage: PropTypes.bool, - /** - * The number of pages to be displayed in the beginning/end of - * the component. For example, marginPagesDisplayed = 2, then the - * component will display at most two pages as margins: - * [<][1][2]...[10]...[19][20][>]. - * - */ - marginPagesDisplayed: PropTypes.number, - /** An onClick handler that gets called with the new page number. */ - onPageChange: PropTypes.func, - /** The total number of pages. */ - pageCount: PropTypes.number.isRequired, - /** - * The number of pages to be displayed. If is bigger than - * pageCount, then all pages will be displayed without gaps. - */ - pageRangeDisplayed: PropTypes.number, - } - - static defaultProps = { - className: undefined, - forcePage: 1, - hideIfOnlyOnePage: true, - marginPagesDisplayed: 1, - onPageChange: () => null, - pageRangeDisplayed: 1, - } +const Pagination = ({ + onPageChange, + className, + forcePage, + pageRangeDisplayed, + marginPagesDisplayed, + hideIfOnlyOnePage, + pageCount, + ...rest +}) => { + const handlePageChange = useCallback( + page => { + onPageChange(page.selected + 1) + }, + [onPageChange], + ) - @bind - onPageChange(page) { - this.props.onPageChange(page.selected + 1) + // Don't show pagination if there is only one page. + if (hideIfOnlyOnePage && pageCount === 1) { + return null } - render() { - const { - className, - forcePage, - pageRangeDisplayed, - marginPagesDisplayed, - hideIfOnlyOnePage, - onPageChange, - pageCount, - ...rest - } = this.props + const containerClassNames = classnames(style.pagination, className) + const breakClassNames = classnames(style.item, style.itemBreak) + const navigationNextClassNames = classnames(style.item, style.itemNavigationNext) + const navigationPrevClassNames = classnames(style.item, style.itemNavigationPrev) - // Don't show pagination if there is only one page. - if (hideIfOnlyOnePage && pageCount === 1) { - return null - } + return ( + } + nextClassName={navigationNextClassNames} + nextLinkClassName={style.link} + nextLabel={} + containerClassName={containerClassNames} + pageClassName={style.item} + breakClassName={breakClassNames} + pageLinkClassName={style.link} + disabledClassName={style.itemDisabled} + activeClassName={style.itemActive} + forcePage={forcePage - 1} + pageRangeDisplayed={pageRangeDisplayed} + marginPagesDisplayed={marginPagesDisplayed} + onPageChange={handlePageChange} + pageCount={pageCount} + {...rest} + /> + ) +} - const containerClassNames = classnames(style.pagination, className) - const breakClassNames = classnames(style.item, style.itemBreak) - const navigationNextClassNames = classnames(style.item, style.itemNavigationNext) - const navigationPrevClassNames = classnames(style.item, style.itemNavigationPrev) +Pagination.propTypes = { + className: PropTypes.string, + /** Page to be displayed immediately. */ + forcePage: PropTypes.number, + /** A flag indicating whether the pagination should be hidden when there is + * only one page. + */ + hideIfOnlyOnePage: PropTypes.bool, + /** + * The number of pages to be displayed in the beginning/end of + * the component. For example, marginPagesDisplayed = 2, then the + * component will display at most two pages as margins: + * [<][1][2]...[10]...[19][20][>]. + * + */ + marginPagesDisplayed: PropTypes.number, + /** An onClick handler that gets called with the new page number. */ + onPageChange: PropTypes.func, + /** The total number of pages. */ + pageCount: PropTypes.number.isRequired, + /** + * The number of pages to be displayed. If is bigger than + * pageCount, then all pages will be displayed without gaps. + */ + pageRangeDisplayed: PropTypes.number, +} - return ( - } - nextClassName={navigationNextClassNames} - nextLinkClassName={style.link} - nextLabel={} - containerClassName={containerClassNames} - pageClassName={style.item} - breakClassName={breakClassNames} - pageLinkClassName={style.link} - disabledClassName={style.itemDisabled} - activeClassName={style.itemActive} - forcePage={forcePage - 1} - pageRangeDisplayed={pageRangeDisplayed} - marginPagesDisplayed={marginPagesDisplayed} - onPageChange={this.onPageChange} - pageCount={pageCount} - {...rest} - /> - ) - } +Pagination.defaultProps = { + className: undefined, + forcePage: 1, + hideIfOnlyOnePage: true, + marginPagesDisplayed: 1, + onPageChange: () => null, + pageRangeDisplayed: 1, } export default Pagination From 8ae0d579efbde00f08d5af8ad9f4901d4d9dfb5d Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Mon, 28 Aug 2023 16:08:22 +0200 Subject: [PATCH 04/12] console: Refactor progressBar --- pkg/webui/components/progress-bar/index.js | 285 ++++++++++----------- 1 file changed, 141 insertions(+), 144 deletions(-) diff --git a/pkg/webui/components/progress-bar/index.js b/pkg/webui/components/progress-bar/index.js index 905e13b760..a312429282 100644 --- a/pkg/webui/components/progress-bar/index.js +++ b/pkg/webui/components/progress-bar/index.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { PureComponent } from 'react' +import React, { useEffect, useState } from 'react' import classnames from 'classnames' import { defineMessages } from 'react-intl' @@ -29,153 +29,150 @@ const m = defineMessages({ percentage: '{percentage, number, percent} finished', }) -export default class ProgressBar extends PureComponent { - static propTypes = { - /* The class to be attached to the bar. */ - barClassName: PropTypes.string, - children: PropTypes.node, - /* The class to be attached to the outer container. */ - className: PropTypes.string, - /* The current progress value, used in conjunction with the `target` value. */ - current: PropTypes.number, - headerTargetMessage: PropTypes.message, - itemName: PropTypes.message, - /* Current percentage. */ - percentage: PropTypes.number, - /* Flag indicating whether an ETA estimation is shown. */ - showEstimation: PropTypes.bool, - /* Flag indicating whether a header with current and target is shown is shown. */ - showHeader: PropTypes.bool, - /* Flag indicating whether a status text is shown (percentage value). */ - showStatus: PropTypes.bool, - /* The target value, used in conjunction with the `current` value. */ - target: PropTypes.number, - warn: PropTypes.number, - } - - static defaultProps = { - barClassName: undefined, - children: undefined, - className: undefined, - current: 0, - percentage: undefined, - showEstimation: true, - showStatus: false, - showHeader: false, - target: 1, - headerTargetMessage: undefined, - itemName: undefined, - warn: undefined, - } - - state = { - estimatedDuration: Infinity, - startTime: undefined, - elapsedTime: undefined, - estimations: 0, - } - - static getDerivedStateFromProps(props, state) { - const { current, target, showEstimation } = props - const { percentage = (current / target) * 100 } = props - let { estimatedDuration, startTime, elapsedTime, estimations } = state - - if (!showEstimation) { - return { estimatedDuration, startTime, elapsedTime, estimations } +const ProgressBar = props => { + const { + barClassName, + children, + className, + current, + headerTargetMessage, + itemName, + showEstimation, + showHeader, + showStatus, + target, + warn, + } = props + const { percentage = (current / target) * 100 } = props + + const [estimatedDuration, setEstimatedDuration] = useState(Infinity) + const [startTime, setStartTime] = useState() + const [estimations, setEstimations] = useState(0) + + useEffect(() => { + if (showEstimation) { + const fraction = Math.max(0, Math.min(1, percentage / 100)) + + if (fraction === 0) { + setStartTime(Date.now()) + setEstimatedDuration(Infinity) + setEstimations(0) + } else { + const elapsedTime = Date.now() - startTime + const newEstimatedDuration = Math.max(0, elapsedTime * (100 / fraction)) + + if (estimations >= 3 || newEstimatedDuration === Infinity || !startTime) { + setEstimatedDuration(newEstimatedDuration) + } + + setEstimations(estimations + 1) + } } - - if (percentage === 0) { - startTime = Date.now() - return { estimatedDuration: Infinity, startTime, elapsedTime, estimations: 0 } + }, [current, percentage, showEstimation, startTime, estimations]) + + const fraction = Math.max(0, Math.min(1, percentage / 100)) + const displayPercentage = (fraction || 0) * 100 + let displayEstimation = null + + if (showEstimation && percentage < 100) { + const now = Date.now() + let eta = new Date(startTime + estimatedDuration) + if (eta <= now) { + // Avoid estimations in the past. + eta = new Date(now + 1000) } - - elapsedTime = Date.now() - startTime - estimatedDuration = Math.max(0, elapsedTime * (100 / percentage)) - estimations++ - - return { estimatedDuration, startTime, elapsedTime, estimations } + displayEstimation = + !showEstimation || + estimations < 3 || // Avoid inaccurate early estimations. + estimatedDuration === Infinity || + !startTime ? null : ( +
    + + Estimated completion + +
    + ) } - render() { - const { - current, - target, - showStatus, - showEstimation, - className, - children, - showHeader, - headerTargetMessage, - itemName, - warn, - barClassName, - } = this.props - const { percentage = (current / target) * 100 } = this.props - const { estimatedDuration, startTime, estimations } = this.state - const fraction = Math.max(0, Math.min(1, percentage / 100)) - const displayPercentage = (fraction || 0) * 100 - let displayEstimation = null - - if (showEstimation && percentage < 100) { - const now = Date.now() - let eta = new Date(startTime + estimatedDuration) - if (eta <= now) { - // Avoid estimations in the past. - eta = new Date(now + 1000) - } - displayEstimation = - !showEstimation || - estimations < 3 || // Avoid inaccurate early estimations. - estimatedDuration === Infinity || - !startTime ? null : ( -
    - - Estimated completion - -
    - ) - } - - const fillerCls = classnames(style.filler, { - [style.warn]: warn >= 80, - [style.limit]: warn >= 100, - }) - - return ( -
    - {showHeader && ( -
    -

    - - {current} {itemName} - -

    - {headerTargetMessage} -
    - )} -
    -
    + const fillerCls = classnames(style.filler, { + [style.warn]: warn >= 80, + [style.limit]: warn >= 100, + }) + + return ( +
    + {showHeader && ( +
    +

    + + {current} {itemName} + +

    + {headerTargetMessage}
    - {showStatus && ( -
    - {this.props.percentage === undefined && !showHeader && ( -
    - ( - ) -
    - )} - {children} - {this.props.percentage !== undefined && ( - - )} - {displayEstimation} -
    - )} + )} +
    +
    - ) - } + {showStatus && ( +
    + {percentage === undefined && !showHeader && ( +
    + ( + ) +
    + )} + {children} + {percentage !== undefined && ( + + )} + {displayEstimation} +
    + )} +
    + ) } + +ProgressBar.propTypes = { + /* The class to be attached to the bar. */ + barClassName: PropTypes.string, + children: PropTypes.node, + /* The class to be attached to the outer container. */ + className: PropTypes.string, + /* The current progress value, used in conjunction with the `target` value. */ + current: PropTypes.number, + headerTargetMessage: PropTypes.message, + itemName: PropTypes.message, + /* Current percentage. */ + percentage: PropTypes.number, + /* Flag indicating whether an ETA estimation is shown. */ + showEstimation: PropTypes.bool, + /* Flag indicating whether a header with current and target is shown is shown. */ + showHeader: PropTypes.bool, + /* Flag indicating whether a status text is shown (percentage value). */ + showStatus: PropTypes.bool, + /* The target value, used in conjunction with the `current` value. */ + target: PropTypes.number, + warn: PropTypes.number, +} + +ProgressBar.defaultProps = { + barClassName: undefined, + children: undefined, + className: undefined, + current: 0, + percentage: undefined, + showEstimation: true, + showStatus: false, + showHeader: false, + target: 1, + headerTargetMessage: undefined, + itemName: undefined, + warn: undefined, +} + +export default ProgressBar From f86c9015b93d514695a9399ca4b92e6ac44e607b Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Mon, 28 Aug 2023 16:08:57 +0200 Subject: [PATCH 05/12] console: Refactor byte input --- pkg/webui/components/input/byte.js | 306 ++++++++++++++--------------- 1 file changed, 149 insertions(+), 157 deletions(-) diff --git a/pkg/webui/components/input/byte.js b/pkg/webui/components/input/byte.js index dafdbf8f43..595be52017 100644 --- a/pkg/webui/components/input/byte.js +++ b/pkg/webui/components/input/byte.js @@ -12,9 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react' +import React, { useCallback } from 'react' import classnames from 'classnames' -import bind from 'autobind-decorator' import MaskedInput from 'react-text-mask' import PropTypes from '@ttn-lw/lib/prop-types' @@ -56,170 +55,163 @@ const upper = str => str.toUpperCase() const clean = str => (typeof str === 'string' ? str.replace(voidChars, '') : str) -export default class ByteInput extends React.Component { - static propTypes = { - className: PropTypes.string, - max: PropTypes.number, - min: PropTypes.number, - onBlur: PropTypes.func, - onChange: PropTypes.func.isRequired, - onFocus: PropTypes.func, - placeholder: PropTypes.message, - showPerChar: PropTypes.bool, - unbounded: PropTypes.bool, - value: PropTypes.string.isRequired, - } - - static defaultProps = { - className: undefined, - min: 0, - max: undefined, - placeholder: undefined, - showPerChar: false, - onBlur: () => null, - onFocus: () => null, - unbounded: false, - } - - input = React.createRef() - - static validate(value, props) { - const { min = 0, max = 256 } = props - const len = Math.floor(value.length / 2) - return min <= len && len <= max - } - - render() { - const { - onBlur, - value, - className, - min, - max, - onChange, - placeholder, - showPerChar, - unbounded, - ...rest - } = this.props - - // Instead of calculating the max width dynamically, which leads to various issues - // with pasting, it's better to use a high max value for unbounded inputs instead. - const calculatedMax = max || 4096 - - if (!unbounded && typeof max !== 'number') { - warn( - 'Byte input has been setup without `max` prop. Always use a max prop unless using `unbounded`', - ) - } - - return ( - - ) - } - - @bind - onChange(evt) { - const { value: oldValue, unbounded } = this.props - const data = evt?.nativeEvent?.data - - // Due to the way that react-text-mask works, it is not possible to - // store the cleaned value, since it would create ambiguity between - // values like `AA` and `AA `. This causes backspaces to not work - // if it targets the space character, since the deleted space would - // be re-added right away. Hence, unbounded inputs need to remove - // the space paddings manually. - let value = unbounded ? evt.target.value : clean(evt.target.value) - - // Make sure values entered at the end of the input (with placeholders) - // are added as expected. `selectionStart` cannot be used due to - // inconsistent behavior on Android phones. - if ( - evt.target.value.endsWith(PLACEHOLDER_CHAR) && - data && - hex.test(data) && - oldValue === value - ) { - value += data - } - - this.props.onChange({ - target: { - name: evt.target.name, - value, - }, - }) - } - - @bind - onBlur(evt) { - this.props.onBlur({ - relatedTarget: evt.relatedTarget, - target: { - name: evt.target.name, - value: clean(evt.target.value), - }, - }) - } - - @bind - onCopy(evt) { +const ByteInput = ({ + onBlur, + value, + className, + min, + max, + onChange, + placeholder, + showPerChar, + unbounded, + ...rest +}) => { + const onCopy = useCallback(evt => { const input = evt.target - const value = input.value.substr( + const selectedValue = input.value.substr( input.selectionStart, input.selectionEnd - input.selectionStart, ) - evt.clipboardData.setData('text/plain', clean(value)) + evt.clipboardData.setData('text/plain', clean(selectedValue)) evt.preventDefault() - } - - @bind - onPaste(evt) { - // Ignore empty pastes. - if (evt?.clipboardData?.getData('text/plain')?.length === 0) { - return - } - const { unbounded } = this.props - const val = evt.target.value - const cleanedSelection = clean( - val.substr( - evt.target.selectionStart, - Math.max(1, evt.target.selectionEnd - evt.target.selectionStart), - ), - ) - - // To avoid the masked input from cutting off characters when the cursor - // is placed in the mask placeholders, the placeholder chars are removed before - // the paste is applied, unless the user made a selection to paste into. - // This will ensure a consistent pasting experience. - if (!unbounded && cleanedSelection === '') { - evt.target.value = val.replace(voidChars, '') - } - } + }, []) + + const onPaste = useCallback( + evt => { + // Ignore empty pastes. + if (evt?.clipboardData?.getData('text/plain')?.length === 0) { + return + } + const val = evt.target.value + const cleanedSelection = clean( + val.substr( + evt.target.selectionStart, + Math.max(1, evt.target.selectionEnd - evt.target.selectionStart), + ), + ) - @bind - onCut(evt) { + // To avoid the masked input from cutting off characters when the cursor + // is placed in the mask placeholders, the placeholder chars are removed before + // the paste is applied, unless the user made a selection to paste into. + // This will ensure a consistent pasting experience. + if (!unbounded && cleanedSelection === '') { + evt.target.value = val.replace(voidChars, '') + } + }, + [unbounded], + ) + + const onChangeCallback = useCallback( + evt => { + const { value: oldValue, unbounded } = rest + const data = evt?.nativeEvent?.data + + // Due to the way that react-text-mask works, it is not possible to + // store the cleaned value, since it would create ambiguity between + // values like `AA` and `AA `. This causes backspaces to not work + // if it targets the space character, since the deleted space would + // be re-added right away. Hence, unbounded inputs need to remove + // the space paddings manually. + let value = unbounded ? evt.target.value : clean(evt.target.value) + + // Make sure values entered at the end of the input (with placeholders) + // are added as expected. `selectionStart` cannot be used due to + // inconsistent behavior on Android phones. + if ( + evt.target.value.endsWith(PLACEHOLDER_CHAR) && + data && + hex.test(data) && + oldValue === value + ) { + value += data + } + + onChange({ + target: { + name: evt.target.name, + value, + }, + }) + }, + [onChange, rest], + ) + + const onBlurCallback = useCallback( + evt => { + onBlur({ + relatedTarget: evt.relatedTarget, + target: { + name: evt.target.name, + value: clean(evt.target.value), + }, + }) + }, + [onBlur], + ) + + const onCut = useCallback(evt => { evt.preventDefault() // Recreate cut action by deleting and reusing copy handler. document.execCommand('copy') document.execCommand('delete') + }, []) + + // Instead of calculating the max width dynamically, which leads to various issues + // with pasting, it's better to use a high max value for unbounded inputs instead. + const calculatedMax = max || 4096 + + if (!unbounded && typeof max !== 'number') { + warn( + 'Byte input has been setup without `max` prop. Always use a max prop unless using `unbounded`', + ) } + + return ( + + ) } + +ByteInput.propTypes = { + className: PropTypes.string, + max: PropTypes.number, + min: PropTypes.number, + onBlur: PropTypes.func, + onChange: PropTypes.func.isRequired, + onFocus: PropTypes.func, + placeholder: PropTypes.message, + showPerChar: PropTypes.bool, + unbounded: PropTypes.bool, + value: PropTypes.string.isRequired, +} + +ByteInput.defaultProps = { + className: undefined, + min: 0, + max: undefined, + placeholder: undefined, + showPerChar: false, + onBlur: () => null, + onFocus: () => null, + unbounded: false, +} + +export default ByteInput From 8c045682ca1be540ea5fcd720d00b6f26282958f Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Mon, 28 Aug 2023 16:09:34 +0200 Subject: [PATCH 06/12] console: Refactor toggled component --- pkg/webui/components/input/toggled.js | 91 +++++++++++++-------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/pkg/webui/components/input/toggled.js b/pkg/webui/components/input/toggled.js index 7d68b73f06..54d42f7604 100644 --- a/pkg/webui/components/input/toggled.js +++ b/pkg/webui/components/input/toggled.js @@ -12,8 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { Component } from 'react' -import bind from 'autobind-decorator' +import React, { useCallback } from 'react' import classnames from 'classnames' import Checkbox from '@ttn-lw/components/checkbox' @@ -25,55 +24,55 @@ import style from './toggled.styl' import Input from '.' -class Toggled extends Component { - @bind - handleCheckboxChange(event) { - const enabled = event.target.checked - const { value } = this.props.value +const Toggled = ({ valueProp, onChange, type, enabledMessage, className, children, ...rest }) => { + const handleCheckboxChange = useCallback( + event => { + const enabled = event.target.checked + const { value } = valueProp - this.props.onChange({ value, enabled }, true) - } + onChange({ value, enabled }, true) + }, + [onChange, valueProp], + ) - @bind - handleInputChange(value) { - const { enabled } = this.props.value + const handleInputChange = useCallback( + value => { + const { enabled } = valueProp - this.props.onChange({ value, enabled }) - } + onChange({ value, enabled }) + }, + [onChange, valueProp], + ) - render() { - const { value, type, enabledMessage, className, children, ...rest } = this.props + const isEnabled = valueProp.enabled || false + const checkboxId = `${rest.id}_checkbox` - const isEnabled = value.enabled || false - const checkboxId = `${rest.id}_checkbox` - - return ( -
    -
    - - {children} -
    - {isEnabled && ( - +
    + + {children}
    - ) - } + {isEnabled && ( + + )} +
    + ) } Toggled.propTypes = { @@ -90,7 +89,7 @@ Toggled.propTypes = { readOnly: PropTypes.bool, type: PropTypes.string, valid: PropTypes.bool, - value: PropTypes.shape({ + valueProp: PropTypes.shape({ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), enabled: PropTypes.bool, }), @@ -109,7 +108,7 @@ Toggled.defaultProps = { placeholder: undefined, readOnly: false, valid: false, - value: undefined, + valueProp: undefined, warning: false, type: 'text', } From 3a128462f2466ebfd87c1da0abdcdacde0a1efd5 Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Mon, 28 Aug 2023 16:09:54 +0200 Subject: [PATCH 07/12] console: Refactor code-editor --- pkg/webui/components/code-editor/index.js | 307 +++++++++++----------- 1 file changed, 149 insertions(+), 158 deletions(-) diff --git a/pkg/webui/components/code-editor/index.js b/pkg/webui/components/code-editor/index.js index 2d2bb3a985..5d79b5415a 100644 --- a/pkg/webui/components/code-editor/index.js +++ b/pkg/webui/components/code-editor/index.js @@ -12,10 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import ReactAce from 'react-ace' import classnames from 'classnames' -import bind from 'autobind-decorator' import PropTypes from '@ttn-lw/lib/prop-types' import combineRefs from '@ttn-lw/lib/combine-refs' @@ -26,169 +25,161 @@ import './ttn-theme' import style from './code-editor.styl' -class CodeEditor extends React.Component { - static propTypes = { - className: PropTypes.string, - /** New commands to add to the editor, see official docs. */ - commands: PropTypes.arrayOf(PropTypes.shape({})), - /** See `https://github.com/ajaxorg/ace/wiki/Configuring-Ace`. */ - editorOptions: PropTypes.shape({}), - editorRef: PropTypes.shape({ current: PropTypes.shape({}) }), - /** The height of the editor. */ - height: PropTypes.string, - /** The language to highlight. */ - language: PropTypes.oneOf(['javascript', 'json']), - /** Maximum lines of code allowed. */ - maxLines: PropTypes.number, - /** Minimum lines of code allowed. */ - minLines: PropTypes.number, - /** The name of the editor (should be unique). */ - name: PropTypes.string.isRequired, - onBlur: PropTypes.func, - onChange: PropTypes.func, - onFocus: PropTypes.func, - /** The default value of the editor. */ - placeholder: PropTypes.string, - /** A flag identifying whether the editor is editable. */ - readOnly: PropTypes.bool, - /** A flag indicating whether the editor should scroll to the bottom when - * the value has been updated, useful for logging use cases. - */ - scrollToBottom: PropTypes.bool, - showGutter: PropTypes.bool, - /** The current value of the editor. */ - value: PropTypes.string, - } - - static defaultProps = { - className: undefined, - commands: undefined, - editorOptions: undefined, - height: '30rem', - language: 'javascript', - maxLines: Infinity, - minLines: 1, - onBlur: () => null, - onChange: () => null, - onFocus: () => null, - placeholder: '', - readOnly: false, - scrollToBottom: false, - showGutter: true, - value: '', - editorRef: null, - } - - constructor(props) { - super(props) - - this.state = { focus: false } - this.aceRef = React.createRef() - } - - @bind - onFocus(evt) { - const { onFocus } = this.props - - this.setState({ focus: true }, () => { +const CodeEditor = ({ + className, + language, + name, + value, + placeholder, + readOnly, + editorOptions, + height, + showGutter, + minLines, + maxLines, + commands, + editorRef, + onBlur, + onChange, + onFocus, + scrollToBottom, +}) => { + const [focus, setFocus] = useState(false) + const aceRef = useRef() + const oldValue = useRef(value) + + const handleFocus = useCallback( + evt => { + setFocus(true) onFocus(evt) - }) - } + }, + [onFocus], + ) - @bind - onBlur(evt) { - const { onBlur } = this.props - - this.setState({ focus: false }, () => { + const handleBlur = useCallback( + evt => { + setFocus(false) onBlur(evt) - }) - } - - @bind - onChange(evt) { - const { onChange } = this.props - - onChange(evt) - } - - componentDidUpdate({ value }) { - const { value: oldValue, scrollToBottom } = this.props - - if (scrollToBottom && value !== oldValue) { - const row = this.aceRef.current.editor.session.getLength() - this.aceRef.current.editor.gotoLine(row) + }, + [onBlur], + ) + + const handleChange = useCallback( + evt => { + onChange(evt) + }, + [onChange], + ) + + const empty = !value || value === '' + const currentValue = empty && !focus ? placeholder : value + + useEffect(() => { + if (scrollToBottom && value !== oldValue.current) { + const row = aceRef.current.editor.session.getLength() + aceRef.current.editor.gotoLine(row) + oldValue.current = value } + }, [scrollToBottom, value]) + + const editorCls = classnames(className, style.wrapper, { + [style.focus]: focus, + [style.readOnly]: readOnly, + }) + + const options = { + tabSize: 2, + useSoftTabs: true, + fontFamily: '"IBM Plex Mono", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace', + fontSize: '13px', + highlightSelectedWord: true, + displayIndentGuides: true, + showFoldWidgets: false, + behavioursEnabled: !(readOnly || empty), + ...editorOptions, } - render() { - const { - className, - language, - name, - value, - placeholder, - readOnly, - editorOptions, - height, - showGutter, - minLines, - maxLines, - commands, - editorRef, - } = this.props - - const { focus } = this.state - - const empty = !value || value === '' - const currentValue = empty && !focus ? placeholder : value - - const editorCls = classnames(className, style.wrapper, { - [style.focus]: focus, - [style.readOnly]: readOnly, - }) - - const options = { - tabSize: 2, - useSoftTabs: true, - fontFamily: '"IBM Plex Mono", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace', - fontSize: '13px', - highlightSelectedWord: true, - displayIndentGuides: true, - showFoldWidgets: false, - behavioursEnabled: !(readOnly || empty), - ...editorOptions, - } + return ( +
    + +
    + ) +} - return ( -
    - -
    - ) - } +CodeEditor.propTypes = { + className: PropTypes.string, + /** New commands to add to the editor, see official docs. */ + commands: PropTypes.arrayOf(PropTypes.shape({})), + /** See `https://github.com/ajaxorg/ace/wiki/Configuring-Ace`. */ + editorOptions: PropTypes.shape({}), + editorRef: PropTypes.shape({ current: PropTypes.shape({}) }), + /** The height of the editor. */ + height: PropTypes.string, + /** The language to highlight. */ + language: PropTypes.oneOf(['javascript', 'json']), + /** Maximum lines of code allowed. */ + maxLines: PropTypes.number, + /** Minimum lines of code allowed. */ + minLines: PropTypes.number, + /** The name of the editor (should be unique). */ + name: PropTypes.string.isRequired, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + /** The default value of the editor. */ + placeholder: PropTypes.string, + /** A flag identifying whether the editor is editable. */ + readOnly: PropTypes.bool, + /** A flag indicating whether the editor should scroll to the bottom when + * the value has been updated, useful for logging use cases. + */ + scrollToBottom: PropTypes.bool, + showGutter: PropTypes.bool, + /** The current value of the editor. */ + value: PropTypes.string, +} + +CodeEditor.defaultProps = { + className: undefined, + commands: undefined, + editorOptions: undefined, + height: '30rem', + language: 'javascript', + maxLines: Infinity, + minLines: 1, + onBlur: () => null, + onChange: () => null, + onFocus: () => null, + placeholder: '', + readOnly: false, + scrollToBottom: false, + showGutter: true, + value: '', + editorRef: null, } export default CodeEditor From a3f50cf34f531b40f57deecaac2d18f7f682c62f Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Mon, 28 Aug 2023 16:12:24 +0200 Subject: [PATCH 08/12] console: Refactor radio component --- .../components/radio-button/group/index.js | 105 ++++++++---------- 1 file changed, 47 insertions(+), 58 deletions(-) diff --git a/pkg/webui/components/radio-button/group/index.js b/pkg/webui/components/radio-button/group/index.js index f1154a323b..7d8aec33b0 100644 --- a/pkg/webui/components/radio-button/group/index.js +++ b/pkg/webui/components/radio-button/group/index.js @@ -12,8 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react' -import bind from 'autobind-decorator' +import React, { useCallback, useEffect, useState } from 'react' import classnames from 'classnames' import PropTypes from '@ttn-lw/lib/prop-types' @@ -36,72 +35,62 @@ const findCheckedRadio = children => { return value } -class RadioGroup extends React.Component { - constructor(props) { - super(props) - - let value +const RadioGroup = props => { + const { className, name, disabled, horizontal, onChange, children } = props + const [value, setValue] = useState(() => { if ('value' in props) { - value = props.value + return props.value } else if ('initialValue' in props) { - value = props.initialValue - } else { - value = findCheckedRadio(props.children) + return props.initialValue } - this.state = { value } - } + return findCheckedRadio(children) + }) - static getDerivedStateFromProps(props, state) { + useEffect(() => { if ('value' in props) { - return { value: props.value } - } - - const value = findCheckedRadio(props.children) - if (value && value !== state.value) { - return { value } - } - - return null - } - - @bind - handleRadioChange(event) { - const { onChange } = this.props - const { target } = event - - // Retain boolean type if the value was initially provided as boolean. - const value = typeof this.props.value === 'boolean' ? target.value === 'true' : target.value - - if (!('value' in this.props)) { - this.setState({ value }) + setValue(props.value) + } else { + const foundValue = findCheckedRadio(children) + if (foundValue && foundValue !== value) { + setValue(foundValue) + } } - - onChange(value) + }, [props, children, value]) + + const handleRadioChange = useCallback( + event => { + const { target } = event + + // Retain boolean type if the value was initially provided as boolean. + const value = typeof props.value === 'boolean' ? target.value === 'true' : target.value + + if (!('value' in props)) { + setValue(value) + } + + onChange(value) + }, + [onChange, props], + ) + + const ctx = { + className: style.groupRadio, + onChange: handleRadioChange, + disabled, + value, + name, } - render() { - const { className, name, disabled, horizontal, children } = this.props - const { value } = this.state - - const ctx = { - className: style.groupRadio, - onChange: this.handleRadioChange, - disabled, - value, - name, - } - - const cls = classnames(className, style.group, { - [style.horizontal]: horizontal, - }) + const cls = classnames(className, style.group, { + [style.horizontal]: horizontal, + }) - return ( -
    - {children} -
    - ) - } + return ( +
    + {children} +
    + ) } RadioGroup.propTypes = { From d1bc90ef68bb9eda469c9b813765837794faf3a7 Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Mon, 28 Aug 2023 16:19:26 +0200 Subject: [PATCH 09/12] console: Refactor input and radio --- pkg/webui/components/input/index.js | 525 ++++++++++----------- pkg/webui/components/radio-button/radio.js | 233 +++++---- 2 files changed, 376 insertions(+), 382 deletions(-) diff --git a/pkg/webui/components/input/index.js b/pkg/webui/components/input/index.js index c607273e81..d500eaf6dc 100644 --- a/pkg/webui/components/input/index.js +++ b/pkg/webui/components/input/index.js @@ -12,10 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react' -import { injectIntl, defineMessages } from 'react-intl' +import React, { useCallback, useImperativeHandle, useRef, useState } from 'react' +import { defineMessages, useIntl } from 'react-intl' import classnames from 'classnames' -import bind from 'autobind-decorator' import Icon from '@ttn-lw/components/icon' import Spinner from '@ttn-lw/components/spinner' @@ -39,101 +38,47 @@ const m = defineMessages({ hideValue: 'Hide value', }) -class Input extends React.Component { - static propTypes = { - action: PropTypes.shape({ - ...Button.propTypes, - }), - actionDisable: PropTypes.bool, - append: PropTypes.node, - autoComplete: PropTypes.oneOf([ - 'current-password', - 'email', - 'name', - 'new-password', - 'off', - 'on', - 'url', - 'username', - ]), - children: PropTypes.node, - className: PropTypes.string, - code: PropTypes.bool, - component: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), - disabled: PropTypes.bool, - error: PropTypes.bool, - forwardedRef: PropTypes.shape({ current: PropTypes.shape({}) }), - icon: PropTypes.string, - inputRef: PropTypes.shape({ current: PropTypes.shape({}) }), - inputWidth: PropTypes.inputWidth, - intl: PropTypes.shape({ - formatMessage: PropTypes.func, - }).isRequired, - label: PropTypes.string, - loading: PropTypes.bool, - max: PropTypes.number, - onBlur: PropTypes.func, - onChange: PropTypes.func, - onEnter: PropTypes.func, - onFocus: PropTypes.func, - placeholder: PropTypes.message, - readOnly: PropTypes.bool, - sensitive: PropTypes.bool, - showPerChar: PropTypes.bool, - title: PropTypes.message, - type: PropTypes.string, - valid: PropTypes.bool, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - warning: PropTypes.bool, - } - - static defaultProps = { - action: undefined, - actionDisable: false, - append: null, - autoComplete: 'off', - children: undefined, - className: undefined, - code: false, - component: 'input', - disabled: false, - error: false, - /** Default `inputWidth` value is set programmatically based on input type. */ - inputWidth: undefined, - icon: undefined, - label: undefined, - loading: false, - max: undefined, - onFocus: () => null, - onBlur: () => null, - onChange: () => null, - onEnter: () => null, - placeholder: undefined, - readOnly: false, - sensitive: false, - showPerChar: false, - title: undefined, - type: 'text', - valid: false, - value: '', - warning: false, - inputRef: null, - forwardedRef: null, - } - - constructor(props) { - super(props) - - this.state = { - focus: false, - hidden: props.sensitive, - } - } - - input = React.createRef(null) - - _computeByteInputWidth() { - const { showPerChar, max, sensitive } = this.props +const Input = React.forwardRef((props, ref) => { + const { + action, + actionDisable, + append, + autoComplete, + children, + className, + code, + component, + disabled, + error, + forwardedRef, + icon, + inputRef, + inputWidth, + label, + loading, + max, + onBlur, + onChange, + onEnter, + onFocus, + placeholder, + readOnly, + sensitive, + showPerChar, + title, + type, + valid, + value, + warning, + ...rest + } = props + const [focus, setFocus] = useState(false) + const [hidden, setHidden] = useState(sensitive) + const input = useRef(null) + const intl = useIntl() + + const computeByteInputWidth = useCallback(() => { + const { showPerChar, max } = rest const isSafari = isSafariUserAgent() const maxValue = showPerChar ? Math.ceil(max / 2) : max @@ -151,193 +96,162 @@ class Input extends React.Component { } return `${width}rem` - } + }, [rest, sensitive]) - @bind - handleHideToggleClick() { - this.setState(({ hidden }) => ({ hidden: !hidden })) - } + const handleHideToggleClick = useCallback(() => { + setHidden(prevHidden => !prevHidden) + }, []) - focus() { - if (this.input.current) { - this.input.current.focus() + const focusInput = useCallback(() => { + if (input.current) { + input.current.focus() } - this.setState({ focus: true }) - } + setFocus(true) + }, []) - blur() { - if (this.input.current) { - this.input.current.blur() + const blurInput = useCallback(() => { + if (input.current) { + input.current.blur() } - this.setState({ focus: false }) - } + setFocus(false) + }, []) + + // Expose the 'focus' and 'blur' methods to the parent component + useImperativeHandle(ref, () => ({ + focus: focusInput, + blur: blurInput, + })) + + const onFocusCallback = useCallback( + evt => { + setFocus(true) + onFocus(evt) + }, + [onFocus], + ) - render() { - const { - action, - actionDisable, - append, - autoComplete, - children, - className, - code, - component, - disabled, - error, - forwardedRef, - icon, - inputRef, - inputWidth, - intl, - label, - loading, - onBlur, - onChange, - onEnter, - onFocus, - placeholder, - readOnly, - sensitive, - showPerChar, - title, - type, - valid, - value, - warning, - ...rest - } = this.props - - const { focus, hidden } = this.state - const inputWidthValue = inputWidth || (type === 'byte' ? undefined : 'm') - - let Component = component - let inputStyle - if (type === 'byte') { - Component = ByteInput - const { max } = this.props - if (!inputWidthValue && max) { - inputStyle = { maxWidth: this._computeByteInputWidth() } - } - } else if (type === 'textarea') { - Component = 'textarea' - } + const onBlurCallback = useCallback( + evt => { + setFocus(false) + onBlur(evt) + }, + [onBlur], + ) - let inputPlaceholder = placeholder - if (typeof placeholder === 'object') { - inputPlaceholder = intl.formatMessage(placeholder, placeholder.values) - } + const onChangeCallback = useCallback( + evt => { + const { value } = evt.target + onChange(value) + }, + [onChange], + ) - let inputTitle = title - if (typeof title === 'object') { - inputTitle = intl.formatMessage(title, title.values) - } + const onKeyDownCallback = useCallback( + evt => { + if (evt.key === 'Enter') { + onEnter(evt.target.value) + } + }, + [onEnter], + ) - const v = valid && (Component.validate ? Component.validate(value, this.props) : true) - const hasAction = Boolean(action) - - const inputCls = classnames(style.inputBox, { - [style[`input-width-${inputWidthValue}`]]: inputWidthValue, - [style.focus]: focus, - [style.error]: error, - [style.readOnly]: readOnly, - [style.warn]: !error && warning, - [style.disabled]: disabled, - [style.code]: code, - [style.actionable]: hasAction, - [style.textarea]: type === 'textarea', - }) - const inputElemCls = classnames(style.input, { [style.hidden]: hidden }) - - const passedProps = { - ...rest, - ...(type === 'byte' ? { showPerChar } : {}), - ref: inputRef ? combineRefs([this.input, inputRef]) : this.input, - } + const inputWidthValue = inputWidth || (type === 'byte' ? undefined : 'm') - return ( -
    -
    - {icon && } - - {v && } - {loading && } - {sensitive && value.length !== 0 && ( - } - trigger="mouseenter" - small - > -
    - {hasAction && ( -
    -
    - )} - {children} -
    - ) + let Component = component + let inputStyle + if (type === 'byte') { + Component = ByteInput + if (!inputWidthValue && max) { + inputStyle = { maxWidth: computeByteInputWidth() } + } + } else if (type === 'textarea') { + Component = 'textarea' } - @bind - onFocus(evt) { - const { onFocus } = this.props - - this.setState({ focus: true }) - onFocus(evt) + let inputPlaceholder = placeholder + if (typeof placeholder === 'object') { + inputPlaceholder = intl.formatMessage(placeholder, placeholder.values) } - @bind - onBlur(evt) { - const { onBlur } = this.props - - this.setState({ focus: false }) - onBlur(evt) + let inputTitle = title + if (typeof title === 'object') { + inputTitle = intl.formatMessage(title, title.values) } - @bind - onChange(evt) { - const { onChange } = this.props - const { value } = evt.target + const v = valid && (Component.validate ? Component.validate(value, props) : true) + const hasAction = Boolean(action) + + const inputCls = classnames(style.inputBox, { + [style[`input-width-${inputWidthValue}`]]: inputWidthValue, + [style.focus]: focus, + [style.error]: error, + [style.readOnly]: readOnly, + [style.warn]: !error && warning, + [style.disabled]: disabled, + [style.code]: code, + [style.actionable]: hasAction, + [style.textarea]: type === 'textarea', + }) + const inputElemCls = classnames(style.input, { [style.hidden]: hidden }) - onChange(value) + const passedProps = { + ...rest, + ...(type === 'byte' ? { showPerChar } : {}), + ref: inputRef ? combineRefs([input, inputRef]) : input, } - @bind - onKeyDown(evt) { - if (evt.key === 'Enter') { - this.props.onEnter(evt.target.value) - } - } -} + return ( +
    +
    + {icon && } + + {v && } + {loading && } + {sensitive && value.length !== 0 && ( + } + trigger="mouseenter" + small + > +
    + {hasAction && ( +
    +
    + )} + {children} +
    + ) +}) const Valid = props => { const classname = classnames(style.valid, { @@ -359,7 +273,88 @@ Valid.defaultProps = { show: false, } +Input.propTypes = { + action: PropTypes.shape({ + ...Button.propTypes, + }), + actionDisable: PropTypes.bool, + append: PropTypes.node, + autoComplete: PropTypes.oneOf([ + 'current-password', + 'email', + 'name', + 'new-password', + 'off', + 'on', + 'url', + 'username', + ]), + children: PropTypes.node, + className: PropTypes.string, + code: PropTypes.bool, + component: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + disabled: PropTypes.bool, + error: PropTypes.bool, + forwardedRef: PropTypes.shape({ current: PropTypes.shape({}) }), + icon: PropTypes.string, + inputRef: PropTypes.shape({ current: PropTypes.shape({}) }), + inputWidth: PropTypes.inputWidth, + intl: PropTypes.shape({ + formatMessage: PropTypes.func, + }).isRequired, + label: PropTypes.string, + loading: PropTypes.bool, + max: PropTypes.number, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onEnter: PropTypes.func, + onFocus: PropTypes.func, + placeholder: PropTypes.message, + readOnly: PropTypes.bool, + sensitive: PropTypes.bool, + showPerChar: PropTypes.bool, + title: PropTypes.message, + type: PropTypes.string, + valid: PropTypes.bool, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + warning: PropTypes.bool, +} + +Input.defaultProps = { + action: undefined, + actionDisable: false, + append: null, + autoComplete: 'off', + children: undefined, + className: undefined, + code: false, + component: 'input', + disabled: false, + error: false, + /** Default `inputWidth` value is set programmatically based on input type. */ + inputWidth: undefined, + icon: undefined, + label: undefined, + loading: false, + max: undefined, + onFocus: () => null, + onBlur: () => null, + onChange: () => null, + onEnter: () => null, + placeholder: undefined, + readOnly: false, + sensitive: false, + showPerChar: false, + title: undefined, + type: 'text', + valid: false, + value: '', + warning: false, + inputRef: null, + forwardedRef: null, +} + Input.Toggled = Toggled Input.Generate = Generate -export default injectIntl(Input, { forwardRef: true }) +export default Input diff --git a/pkg/webui/components/radio-button/radio.js b/pkg/webui/components/radio-button/radio.js index 738132010e..4e3fe8261f 100644 --- a/pkg/webui/components/radio-button/radio.js +++ b/pkg/webui/components/radio-button/radio.js @@ -12,8 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react' -import bind from 'autobind-decorator' +import React, { useCallback, useContext, useRef } from 'react' import classnames from 'classnames' import Message from '@ttn-lw/lib/components/message' @@ -24,125 +23,125 @@ import { RadioGroupContext } from './group' import style from './radio-button.styl' -class RadioButton extends React.PureComponent { - static contextType = RadioGroupContext - - static propTypes = { - autoFocus: PropTypes.bool, - checked: PropTypes.bool, - className: PropTypes.string, - disabled: PropTypes.bool, - id: PropTypes.string, - label: PropTypes.message, - name: PropTypes.string, - onBlur: PropTypes.func, - onChange: PropTypes.func, - onFocus: PropTypes.func, - readOnly: PropTypes.bool, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), +const RadioButton = ({ + className, + name, + label, + disabled, + readOnly, + autoFocus, + onBlur, + onFocus, + value, + checked, + id, + onChange, +}) => { + const input = useRef() + const context = useContext(RadioGroupContext) + + const handleChange = useCallback( + event => { + if (context) { + const { onChange: groupOnChange } = context + groupOnChange(event) + } + + onChange(event) + }, + [onChange, context], + ) + + const focus = useCallback( + val => { + if (input && input.current) { + input.current.focus() + } + + onFocus(val) + }, + [onFocus], + ) + + const blur = useCallback( + val => { + if (input && input.current) { + input.current.blur() + } + + onBlur(val) + }, + [onBlur], + ) + + const radioProps = {} + let groupCls + if (context) { + radioProps.name = context.name + radioProps.disabled = disabled || context.disabled + radioProps.checked = value === context.value + groupCls = context.className + } else { + radioProps.name = name + radioProps.disabled = disabled + radioProps.checked = checked + radioProps.value = value } - static defaultProps = { - className: undefined, - checked: false, - disabled: false, - label: undefined, - name: undefined, - readOnly: false, - value: undefined, - autoFocus: false, - id: undefined, - onChange: () => null, - onBlur: () => null, - onFocus: () => null, - } - - constructor(props) { - super(props) - - this.input = React.createRef() - } - - @bind - handleChange(event) { - const { onChange } = this.props - - if (this.context) { - const { onChange: groupOnChange } = this.context - groupOnChange(event) - } - - onChange(event) - } - - @bind - focus() { - if (this.input && this.input.current) { - this.input.current.focus() - } - } + const cls = classnames(className, style.wrapper, groupCls, { + [style.disabled]: radioProps.disabled, + }) + + return ( + + ) +} - @bind - blur() { - if (this.input && this.input.current) { - this.input.current.blur() - } - } +RadioButton.propTypes = { + autoFocus: PropTypes.bool, + checked: PropTypes.bool, + className: PropTypes.string, + disabled: PropTypes.bool, + id: PropTypes.string, + label: PropTypes.message, + name: PropTypes.string, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + readOnly: PropTypes.bool, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), +} - render() { - const { - className, - name, - label, - disabled, - readOnly, - autoFocus, - onBlur, - onFocus, - value, - checked, - id, - } = this.props - - const radioProps = {} - let groupCls - if (this.context) { - radioProps.name = this.context.name - radioProps.disabled = disabled || this.context.disabled - radioProps.checked = value === this.context.value - groupCls = this.context.className - } else { - radioProps.name = name - radioProps.disabled = disabled - radioProps.checked = checked - radioProps.value = value - } - - const cls = classnames(className, style.wrapper, groupCls, { - [style.disabled]: radioProps.disabled, - }) - - return ( - - ) - } +RadioButton.defaultProps = { + className: undefined, + checked: false, + disabled: false, + label: undefined, + name: undefined, + readOnly: false, + value: undefined, + autoFocus: false, + id: undefined, + onChange: () => null, + onBlur: () => null, + onFocus: () => null, } export default RadioButton From 5f9ce9f656ad4f3e77d07a2f7ae4b6e248965066 Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Mon, 28 Aug 2023 16:41:41 +0200 Subject: [PATCH 10/12] console: Fix byte and inpur --- pkg/webui/components/input/byte.js | 11 +++++------ pkg/webui/components/input/index.js | 8 +++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/pkg/webui/components/input/byte.js b/pkg/webui/components/input/byte.js index 595be52017..3f0a0c2129 100644 --- a/pkg/webui/components/input/byte.js +++ b/pkg/webui/components/input/byte.js @@ -104,7 +104,6 @@ const ByteInput = ({ const onChangeCallback = useCallback( evt => { - const { value: oldValue, unbounded } = rest const data = evt?.nativeEvent?.data // Due to the way that react-text-mask works, it is not possible to @@ -113,7 +112,7 @@ const ByteInput = ({ // if it targets the space character, since the deleted space would // be re-added right away. Hence, unbounded inputs need to remove // the space paddings manually. - let value = unbounded ? evt.target.value : clean(evt.target.value) + let newValue = unbounded ? evt.target.value : clean(evt.target.value) // Make sure values entered at the end of the input (with placeholders) // are added as expected. `selectionStart` cannot be used due to @@ -122,19 +121,19 @@ const ByteInput = ({ evt.target.value.endsWith(PLACEHOLDER_CHAR) && data && hex.test(data) && - oldValue === value + value === newValue ) { - value += data + newValue += data } onChange({ target: { name: evt.target.name, - value, + value: newValue, }, }) }, - [onChange, rest], + [onChange, value, unbounded], ) const onBlurCallback = useCallback( diff --git a/pkg/webui/components/input/index.js b/pkg/webui/components/input/index.js index d500eaf6dc..a5bc063f66 100644 --- a/pkg/webui/components/input/index.js +++ b/pkg/webui/components/input/index.js @@ -56,7 +56,6 @@ const Input = React.forwardRef((props, ref) => { inputWidth, label, loading, - max, onBlur, onChange, onEnter, @@ -78,9 +77,8 @@ const Input = React.forwardRef((props, ref) => { const intl = useIntl() const computeByteInputWidth = useCallback(() => { - const { showPerChar, max } = rest const isSafari = isSafariUserAgent() - + const { max } = props const maxValue = showPerChar ? Math.ceil(max / 2) : max const multiplier = isSafari ? 2.1 : 1.8 @@ -96,7 +94,7 @@ const Input = React.forwardRef((props, ref) => { } return `${width}rem` - }, [rest, sensitive]) + }, [sensitive, showPerChar, props]) const handleHideToggleClick = useCallback(() => { setHidden(prevHidden => !prevHidden) @@ -163,7 +161,7 @@ const Input = React.forwardRef((props, ref) => { let inputStyle if (type === 'byte') { Component = ByteInput - if (!inputWidthValue && max) { + if (!inputWidthValue && props.max) { inputStyle = { maxWidth: computeByteInputWidth() } } } else if (type === 'textarea') { From 2cbe6e1190f21da0c08f9925df913a2b3f4017c8 Mon Sep 17 00:00:00 2001 From: Darya Plotnytska Date: Tue, 29 Aug 2023 12:17:18 +0200 Subject: [PATCH 11/12] console: Fix toggled and progress bar --- pkg/webui/components/input/index.js | 3 -- pkg/webui/components/input/toggled.js | 24 +++++++------- pkg/webui/components/progress-bar/index.js | 37 ++++++---------------- 3 files changed, 23 insertions(+), 41 deletions(-) diff --git a/pkg/webui/components/input/index.js b/pkg/webui/components/input/index.js index a5bc063f66..2426d4ee11 100644 --- a/pkg/webui/components/input/index.js +++ b/pkg/webui/components/input/index.js @@ -297,9 +297,6 @@ Input.propTypes = { icon: PropTypes.string, inputRef: PropTypes.shape({ current: PropTypes.shape({}) }), inputWidth: PropTypes.inputWidth, - intl: PropTypes.shape({ - formatMessage: PropTypes.func, - }).isRequired, label: PropTypes.string, loading: PropTypes.bool, max: PropTypes.number, diff --git a/pkg/webui/components/input/toggled.js b/pkg/webui/components/input/toggled.js index 54d42f7604..199e218771 100644 --- a/pkg/webui/components/input/toggled.js +++ b/pkg/webui/components/input/toggled.js @@ -24,27 +24,29 @@ import style from './toggled.styl' import Input from '.' -const Toggled = ({ valueProp, onChange, type, enabledMessage, className, children, ...rest }) => { +const Toggled = props => { const handleCheckboxChange = useCallback( event => { const enabled = event.target.checked - const { value } = valueProp + const { value } = props.value - onChange({ value, enabled }, true) + props.onChange({ value, enabled }, true) }, - [onChange, valueProp], + [props], ) const handleInputChange = useCallback( value => { - const { enabled } = valueProp + const { enabled } = props.value - onChange({ value, enabled }) + props.onChange({ value, enabled }) }, - [onChange, valueProp], + [props], ) - const isEnabled = valueProp.enabled || false + const { value, type, enabledMessage, className, children, ...rest } = props + + const isEnabled = value.enabled || false const checkboxId = `${rest.id}_checkbox` return ( @@ -67,7 +69,7 @@ const Toggled = ({ valueProp, onChange, type, enabledMessage, className, childre {...rest} className={style.input} type="text" - value={valueProp.value || ''} + value={value.value || ''} onChange={handleInputChange} /> )} @@ -89,7 +91,7 @@ Toggled.propTypes = { readOnly: PropTypes.bool, type: PropTypes.string, valid: PropTypes.bool, - valueProp: PropTypes.shape({ + value: PropTypes.shape({ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), enabled: PropTypes.bool, }), @@ -108,7 +110,7 @@ Toggled.defaultProps = { placeholder: undefined, readOnly: false, valid: false, - valueProp: undefined, + value: undefined, warning: false, type: 'text', } diff --git a/pkg/webui/components/progress-bar/index.js b/pkg/webui/components/progress-bar/index.js index a312429282..b300238e94 100644 --- a/pkg/webui/components/progress-bar/index.js +++ b/pkg/webui/components/progress-bar/index.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useEffect, useState } from 'react' +import React, { useMemo, useState } from 'react' import classnames from 'classnames' import { defineMessages } from 'react-intl' @@ -44,31 +44,14 @@ const ProgressBar = props => { warn, } = props const { percentage = (current / target) * 100 } = props - - const [estimatedDuration, setEstimatedDuration] = useState(Infinity) - const [startTime, setStartTime] = useState() + const startTime = useMemo(() => (percentage === 0 ? Date.now() : null), [percentage]) const [estimations, setEstimations] = useState(0) - - useEffect(() => { - if (showEstimation) { - const fraction = Math.max(0, Math.min(1, percentage / 100)) - - if (fraction === 0) { - setStartTime(Date.now()) - setEstimatedDuration(Infinity) - setEstimations(0) - } else { - const elapsedTime = Date.now() - startTime - const newEstimatedDuration = Math.max(0, elapsedTime * (100 / fraction)) - - if (estimations >= 3 || newEstimatedDuration === Infinity || !startTime) { - setEstimatedDuration(newEstimatedDuration) - } - - setEstimations(estimations + 1) - } - } - }, [current, percentage, showEstimation, startTime, estimations]) + const estimatedDuration = useMemo(() => { + const newElapsedTime = Date.now() - startTime + const newEstimatedDuration = Math.max(0, newElapsedTime * (100 / percentage)) + setEstimations(estimations => estimations + 1) + return newEstimatedDuration + }, [percentage, startTime]) const fraction = Math.max(0, Math.min(1, percentage / 100)) const displayPercentage = (fraction || 0) * 100 @@ -116,14 +99,14 @@ const ProgressBar = props => {
    {showStatus && (
    - {percentage === undefined && !showHeader && ( + {props.percentage === undefined && !showHeader && (
    ( )
    )} {children} - {percentage !== undefined && ( + {props.percentage !== undefined && ( Date: Wed, 30 Aug 2023 15:24:02 +0200 Subject: [PATCH 12/12] console: Use max --- pkg/webui/components/input/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/webui/components/input/index.js b/pkg/webui/components/input/index.js index 2426d4ee11..4978443ab3 100644 --- a/pkg/webui/components/input/index.js +++ b/pkg/webui/components/input/index.js @@ -69,6 +69,7 @@ const Input = React.forwardRef((props, ref) => { valid, value, warning, + max, ...rest } = props const [focus, setFocus] = useState(false) @@ -78,7 +79,6 @@ const Input = React.forwardRef((props, ref) => { const computeByteInputWidth = useCallback(() => { const isSafari = isSafariUserAgent() - const { max } = props const maxValue = showPerChar ? Math.ceil(max / 2) : max const multiplier = isSafari ? 2.1 : 1.8 @@ -94,7 +94,7 @@ const Input = React.forwardRef((props, ref) => { } return `${width}rem` - }, [sensitive, showPerChar, props]) + }, [sensitive, showPerChar, max]) const handleHideToggleClick = useCallback(() => { setHidden(prevHidden => !prevHidden) @@ -195,6 +195,7 @@ const Input = React.forwardRef((props, ref) => { const inputElemCls = classnames(style.input, { [style.hidden]: hidden }) const passedProps = { + max, ...rest, ...(type === 'byte' ? { showPerChar } : {}), ref: inputRef ? combineRefs([input, inputRef]) : input,