diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a3212..1f1100f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- TimePicker component by [@amit-y](https://github.com/amit-y) -- DateTimePicker component by [@amit-y](https://github.com/amit-y) - TimeRangePicker component by [@amit-y](https://github.com/amit-y) +## [1.20.0] - 2023-12-18 + +### Added + +- DateTimePicker component by [@amit-y](https://github.com/amit-y) + +## [1.19.0] - 2023-12-18 + +### Added + +- TimePicker component by [@amit-y](https://github.com/amit-y) + +## [1.18.0] - 2023-12-18 + +### Changed + +- SimpleBillboard - add option to enable title's tooltip + change up/down trend arrow colors to grey by [@shahramk](https://github.com/shahramk) + ## [1.17.0] - 2023-12-14 ### Added diff --git a/package-lock.json b/package-lock.json index 097ab7b..95563c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@newrelic/nr-labs-components", - "version": "1.17.0", + "version": "1.20.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@newrelic/nr-labs-components", - "version": "1.17.0", + "version": "1.20.0", "license": "Apache-2.0", "dependencies": { "dayjs": "^1.11.7" diff --git a/package.json b/package.json index 3ccea2c..ed70c55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@newrelic/nr-labs-components", - "version": "1.17.0", + "version": "1.20.0", "description": "New Relic Labs components", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/src/components/date-time-picker/README.md b/src/components/date-time-picker/README.md new file mode 100644 index 0000000..77cc594 --- /dev/null +++ b/src/components/date-time-picker/README.md @@ -0,0 +1,28 @@ +# DateTimePicker + +The `DateTimePicker` component is used to select a date/time value. + +## Usage + +To use the component, simply import and use: + +```jsx +import React, { useState } from 'react'; +import { DateTimePicker } from '@newrelic/nr-labs-components'; + +function MyComponent() { + const [selectedDate, setSelectedDate] = useState(new Date()); + + return ( +
+ +
+ ); +} +``` +### Props + +- date (date): The default date +- onChange (function): A function that is called when the user selects a date +- validFrom (date): only allow date/times starting from the provided value +- validTill (date): only allow date/times ending till the provided value diff --git a/src/components/date-time-picker/index.js b/src/components/date-time-picker/index.js new file mode 100644 index 0000000..7003735 --- /dev/null +++ b/src/components/date-time-picker/index.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import DatePicker from '../date-picker'; +import TimePicker from '../time-picker'; + +import styles from './styles.scss'; + +const DateTimePicker = ({ datetime, validFrom, validTill, onChange }) => ( +
+ + +
+); + +DateTimePicker.propTypes = { + datetime: PropTypes.instanceOf(Date), + validFrom: PropTypes.instanceOf(Date), + validTill: PropTypes.instanceOf(Date), + onChange: PropTypes.func, +}; + +export default DateTimePicker; diff --git a/src/components/date-time-picker/styles.scss b/src/components/date-time-picker/styles.scss new file mode 100644 index 0000000..87447cc --- /dev/null +++ b/src/components/date-time-picker/styles.scss @@ -0,0 +1,5 @@ +.date-time-picker { + display: grid; + grid-template-columns: 96px 76px; + gap: 4px; + } diff --git a/src/components/simple-billboard/README.md b/src/components/simple-billboard/README.md index bc5d604..05eb94c 100644 --- a/src/components/simple-billboard/README.md +++ b/src/components/simple-billboard/README.md @@ -47,6 +47,7 @@ and the code snippet to use the component: name: PropTypes.string, // required - metric name style: PropTypes.object, // optional - SCSS class name for metric name className: PropTypes.string, // optional - SCSS style for metric name + toolTip: PropTypes.bool // optional - enable tool tip for metric name (default: false) }), }; ``` diff --git a/src/components/simple-billboard/index.js b/src/components/simple-billboard/index.js index 053fe00..1d6b5b4 100644 --- a/src/components/simple-billboard/index.js +++ b/src/components/simple-billboard/index.js @@ -8,7 +8,7 @@ import styles from './styles.scss'; /** * @param {Object} metric - metric value, previousValue, optional: prefix, suffix, className, style * @param {Object} statusTrend - optional: className, style - * @param {Object} title - metric name, optional: className, style + * @param {Object} title - metric name, optional: className, style, toolTip * @return {JSX Object} - RENDERING name, value, up/down trend when previousValue present */ const SimpleBillboard = ({ metric, statusTrend = {}, title }) => { @@ -58,20 +58,15 @@ const SimpleBillboard = ({ metric, statusTrend = {}, title }) => { }; const icon = difference > 0 - ? { - type: 'uparrow', - fill: '#02865B', - } - : { - type: 'downarrow', - fill: '#DF2D24', - }; + ? { type: 'uparrow', } + : { type: 'downarrow', }; + return ( { }, [difference]); return ( -
+
{ {changeStatus}
- + {title.toolTip ? ( + +
+ {title.name} +
+
+ ) : (
{ > {title.name}
-
+ )}
); }; @@ -125,6 +131,7 @@ SimpleBillboard.propTypes = { name: PropTypes.string, style: PropTypes.object, className: PropTypes.string, + toolTip: PropTypes.bool, }), }; diff --git a/src/components/simple-billboard/simple-billboard.png b/src/components/simple-billboard/simple-billboard.png index 92ff4e1..b028b3f 100644 Binary files a/src/components/simple-billboard/simple-billboard.png and b/src/components/simple-billboard/simple-billboard.png differ diff --git a/src/components/time-picker/README.md b/src/components/time-picker/README.md new file mode 100644 index 0000000..aaaa2b1 --- /dev/null +++ b/src/components/time-picker/README.md @@ -0,0 +1,28 @@ +# TimePicker + +The `TimePicker` component is used to select a time value from a list of valid times. + +## Usage + +To use the component, simply import and use: + +```jsx +import React, { useState } from 'react'; +import { TimePicker } from '@newrelic/nr-labs-components'; + +function MyComponent() { + const [selectedTime, setSelectedTime] = useState(new Date()); + + return ( +
+ +
+ ); +} +``` +### Props + +- time (date): The default time +- onChange (function): A function that is called when the user selects a time +- validFrom (date): only allow times starting from the provided value +- validTill (date): only allow times ending till the provided value diff --git a/src/components/time-picker/index.js b/src/components/time-picker/index.js new file mode 100644 index 0000000..0777ff6 --- /dev/null +++ b/src/components/time-picker/index.js @@ -0,0 +1,162 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { + Popover, + PopoverTrigger, + PopoverBody, + TextField, + List, + ListItem, + Button, +} from 'nr1'; + +import { + getHourMinuteFromTimeString, + isValidTime, + normalizedDateTime, + isHourMinuteNumbers, +} from './utils'; + +import styles from './styles.scss'; + +const TIME_FORMATTER = new Intl.DateTimeFormat('default', { + hour12: true, + hour: 'numeric', + minute: 'numeric', +}); + +const TIMES_LIST = Array.from({ length: 48 }).map((_, i) => ({ + key: `time${i}`, + value: + `${Math.floor(i / 2) % 12 || 12}`.padStart(2, '0') + + `:${i % 2 ? '30' : '00'} ${i > 23 ? 'pm' : 'am'}`, +})); + +const TimePicker = ({ time, validFrom, validTill, onChange }) => { + const [opened, setOpened] = useState(false); + const [filter, setFilter] = useState(''); + const [times, setTimes] = useState([]); + + useEffect(() => { + if (!filter) { + setTimes( + TIMES_LIST.filter(({ value }) => + isValidTime(value, validFrom, validTill, time) + ) + ); + return; + } + const re = /^(1[0-2]|0?[1-9]):?([0-5]?[0-9])? ?([AaPp][Mm]?)?/; + const [, hr, mi, me] = filter.match(re) || []; + + if (hr) { + let reStr = hr; + if (reStr.length === 1 && Number(hr) < 3) reStr += '[0-9]?'; + reStr += mi ? `:${mi}` : ':[0-9][0-9]'; + if (reStr && me && /^[Aa]/.test(me)) reStr += ' am'; + if (reStr && me && /^[Pp]/.test(me)) reStr += ' pm'; + + const re2 = new RegExp(reStr); + const matches = TIMES_LIST.filter( + ({ value }) => + value.match(re2) && isValidTime(value, validFrom, validTill, time) + ); + if (!matches.length && hr && mi && mi.length === 2) { + if (me && /^[AaPp]/.test(me)) { + const value = `${hr}:${mi} ${/^[Aa]/.test(me) ? 'am' : 'pm'}`; + if (isValidTime(value, validFrom, validTill, time)) + matches.push({ + key: 'time48', + value, + }); + } else { + ['am', 'pm'].forEach((m) => { + const value = `${hr}:${mi} ${m}`; + if (isValidTime(value, validFrom, validTill, time)) + matches.push({ + key: `time48${m}`, + value, + }); + }); + } + } + setTimes(matches); + } + }, [filter]); + + const clickHandler = useCallback((e, t) => { + e.stopPropagation(); + const { hr, mi } = getHourMinuteFromTimeString(t); + if (!isHourMinuteNumbers(hr, mi)) return; + if (onChange) onChange(normalizedDateTime(time, hr, mi)); + setOpened(false); + setFilter(''); + }, []); + + const changeHandler = useCallback((_, o) => { + if (!o) setFilter(''); + setOpened(o); + }); + + const filterChangeHandler = useCallback( + ({ target: { value = '' } } = {}) => setFilter(value), + [] + ); + + const keyDownHandler = useCallback((e) => { + const re = /[0-9APMapm: ]+/g; + if (!re.test(e.key)) e.preventDefault(); + }, []); + + return ( + + + + + +
+ + + {times.map(({ key, value }) => ( + + + + ))} + +
+
+
+ ); +}; + +TimePicker.propTypes = { + time: PropTypes.instanceOf(Date), + validFrom: PropTypes.instanceOf(Date), + validTill: PropTypes.instanceOf(Date), + onChange: PropTypes.func, +}; + +export default TimePicker; diff --git a/src/components/time-picker/styles.scss b/src/components/time-picker/styles.scss new file mode 100644 index 0000000..63d2578 --- /dev/null +++ b/src/components/time-picker/styles.scss @@ -0,0 +1,45 @@ +.time-picker { + box-sizing: border-box; + direction: ltr; + max-height: 178px; + max-width: 98px; + will-change: transform; + display: flex; + flex-direction: column; +} + +.time-list-search { + flex: 1 0 10%; + border-bottom: 1px solid #E7E9EA; + input { + background-color: transparent; + } +} + +.time-list-items { + flex: auto; + overflow-y: auto; +} + +.time-list-item { + height: 24px; + width: 100%; + appearance: none; + background: none; + border: none; + font-size: 12px; + font-family: inherit; + color: #293338; + text-align: left; + cursor: pointer; + padding: 0 12px; + white-space: nowrap; + font-weight: normal; + border-radius: 0; + min-height: 24px; + + + &:hover { + box-shadow: none; + } +} diff --git a/src/components/time-picker/utils.js b/src/components/time-picker/utils.js new file mode 100644 index 0000000..6e83796 --- /dev/null +++ b/src/components/time-picker/utils.js @@ -0,0 +1,40 @@ +const getHourMinuteFromTimeString = (timeString) => { + const parts = timeString.split(/:| /); + return parts.length === 3 + ? { + hr: (Number(parts[0]) % 12) + (parts[2] === 'pm' ? 12 : 0), + mi: Number(parts[1]), + } + : {}; +}; + +const isValidTime = (timeString, validFrom, validTill, date) => { + const { hr, mi } = getHourMinuteFromTimeString(timeString); + if (!isHourMinuteNumbers(hr, mi)) return false; + if (!(date instanceof Date)) return true; + let isValid = true; + const d = normalizedDateTime(date, hr, mi); + if (validFrom instanceof Date) isValid = d >= normalizedDateTime(validFrom); + if (validTill instanceof Date) + isValid = isValid && d <= normalizedDateTime(validTill); + return isValid; +}; + +const normalizedDateTime = (dt = new Date(), hr, mi) => + new Date( + dt.getFullYear(), + dt.getMonth(), + dt.getDate(), + typeof hr === 'number' ? hr : dt.getHours(), + typeof mi === 'number' ? mi : dt.getMinutes() + ); + +const isHourMinuteNumbers = (hr, mi) => + typeof hr === 'number' && typeof mi === 'number'; + +export { + getHourMinuteFromTimeString, + isValidTime, + normalizedDateTime, + isHourMinuteNumbers, +};