From 4441e3d1401aa3fa32943adf53624a2e2031e376 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Wed, 17 Apr 2024 14:51:20 +0200 Subject: [PATCH 01/16] console,account: Add scroll fader component --- pkg/webui/components/scroll-fader/index.js | 100 ++++++++++++++++++ .../components/scroll-fader/scroll-fader.styl | 37 +++++++ 2 files changed, 137 insertions(+) create mode 100644 pkg/webui/components/scroll-fader/index.js create mode 100644 pkg/webui/components/scroll-fader/scroll-fader.styl diff --git a/pkg/webui/components/scroll-fader/index.js b/pkg/webui/components/scroll-fader/index.js new file mode 100644 index 0000000000..8060370e20 --- /dev/null +++ b/pkg/webui/components/scroll-fader/index.js @@ -0,0 +1,100 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { useCallback, useEffect, useRef } from 'react' + +import PropTypes from '@ttn-lw/lib/prop-types' +import combineRefs from '@ttn-lw/lib/combine-refs' + +import style from './scroll-fader.styl' + +// ScrollFader is a component that fades out the content of a container when it +// is scrolled. It is used for scrollable elements that need some visual +// indication that they are scrollable, but do not have a scrollbar. +// The indication only shows when the content is scrolled. + +const ScrollFader = React.forwardRef(({ children, className, fadeHeight }, ref) => { + const internalRef = useRef() + const combinedRef = combineRefs([ref, internalRef]) + + const handleScroll = useCallback(() => { + const container = internalRef.current + const { scrollTop, scrollHeight, clientHeight } = container + const scrollable = scrollHeight - clientHeight + const scrollGradientTop = container.querySelector(`.${style.scrollGradientTop}`) + const scrollGradientBottom = container.querySelector(`.${style.scrollGradientBottom}`) + + if (scrollGradientTop) { + const opacity = scrollTop < fadeHeight ? scrollTop / fadeHeight : 1 + scrollGradientTop.style.opacity = opacity + } + + if (scrollGradientBottom) { + const scrollEnd = scrollable - fadeHeight + const opacity = scrollTop < scrollEnd ? 1 : (scrollable - scrollTop) / fadeHeight + scrollGradientBottom.style.opacity = opacity + } + }, [fadeHeight]) + + useEffect(() => { + const container = internalRef.current + if (!container) return + + const mutationObserver = new MutationObserver(() => { + handleScroll() + }) + + // Run the calculation whenever the children change. + mutationObserver.observe(container, { attributes: false, childList: true, subtree: false }) + + handleScroll() // Call once on mount if needed + container.addEventListener('scroll', handleScroll) + window.addEventListener('resize', handleScroll) + + return () => { + // Cleanup observer and event listeners + mutationObserver.disconnect() + container.removeEventListener('scroll', handleScroll) + window.removeEventListener('resize', handleScroll) + } + }, [handleScroll]) + + return ( +
+
+ {children} +
+
+ ) +}) + +ScrollFader.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, + fadeHeight: PropTypes.number, +} + +ScrollFader.defaultProps = { + className: undefined, + fadeHeight: 40, +} + +export default ScrollFader diff --git a/pkg/webui/components/scroll-fader/scroll-fader.styl b/pkg/webui/components/scroll-fader/scroll-fader.styl new file mode 100644 index 0000000000..092268e825 --- /dev/null +++ b/pkg/webui/components/scroll-fader/scroll-fader.styl @@ -0,0 +1,37 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +$scroll-gradient-height = 1rem + +// Add a gradient to the bottom of the sidebar +// to signify that there is more content below. +.scroll-gradient-top, .scroll-gradient-bottom + position: sticky + left: 0 + right: 0 + height: $scroll-gradient-height + z-index: $zi.slight + opacity: 0 + pointer-events: none + +.scroll-gradient-top + top: 0 + margin-bottom: - $scroll-gradient-height + background: linear-gradient(0deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .05)) + +.scroll-gradient-bottom + bottom: 0 + margin-top: - $scroll-gradient-height + background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .05)) + From bc385e6df0e5bef4fcffdba37e3aa20187b75b3c Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Wed, 17 Apr 2024 14:51:42 +0200 Subject: [PATCH 02/16] console,account: Add search panel component --- pkg/webui/components/icon/index.js | 6 +- pkg/webui/components/search-panel/index.js | 344 ++++++++++++++++++ pkg/webui/components/search-panel/item.js | 62 ++++ .../components/search-panel/looking-luke.js | 59 +++ .../components/search-panel/search-panel.styl | 126 +++++++ pkg/webui/components/search-panel/story.js | 79 ++++ pkg/webui/styles/main.styl | 1 + pkg/webui/styles/variables/tokens.styl | 1 + 8 files changed, 675 insertions(+), 3 deletions(-) create mode 100644 pkg/webui/components/search-panel/index.js create mode 100644 pkg/webui/components/search-panel/item.js create mode 100644 pkg/webui/components/search-panel/looking-luke.js create mode 100644 pkg/webui/components/search-panel/search-panel.styl create mode 100644 pkg/webui/components/search-panel/story.js diff --git a/pkg/webui/components/icon/index.js b/pkg/webui/components/icon/index.js index 308dea1cb5..9ac863ce99 100644 --- a/pkg/webui/components/icon/index.js +++ b/pkg/webui/components/icon/index.js @@ -36,13 +36,13 @@ const Icon = forwardRef((props, ref) => { const classname = classnames(className, { [style.nudgeUp]: nudgeUp, [style.nudgeDown]: nudgeDown, - [style.large]: large, - [style.small]: small, [style.textPaddedLeft]: textPaddedLeft, [style.textPaddedRight]: textPaddedRight, }) - return + const renderedSize = large ? 24 : small ? 16 : size + + return }) Icon.propTypes = { diff --git a/pkg/webui/components/search-panel/index.js b/pkg/webui/components/search-panel/index.js new file mode 100644 index 0000000000..fec0c0a5ab --- /dev/null +++ b/pkg/webui/components/search-panel/index.js @@ -0,0 +1,344 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import classNames from 'classnames' +import React, { useRef, useEffect, useState, useCallback } from 'react' +import { defineMessages, useIntl } from 'react-intl' + +import { APPLICATION, END_DEVICE, GATEWAY, ORGANIZATION } from '@console/constants/entities' + +import Icon, { + IconSearch, + IconApplication, + IconArrowUp, + IconArrowDown, + IconArrowBack, + IconGateway, + IconOrganization, + IconDevice, + IconX, +} from '@ttn-lw/components/icon' +import ScrollFader from '@ttn-lw/components/scroll-fader' + +import Message from '@ttn-lw/lib/components/message' + +import PropTypes from '@ttn-lw/lib/prop-types' +import sharedMessages from '@ttn-lw/lib/shared-messages' +import useDebounce from '@ttn-lw/lib/hooks/use-debounce' + +import Spinner from '../spinner' +import Overlay from '../overlay' + +import PanelItem from './item' +import LookingLuke from './looking-luke' + +import style from './search-panel.styl' + +const m = defineMessages({ + noResultsFound: 'No results found', + noResultsSuggestion: 'Try searching for IDs names, attributes, EUIs or descriptions of:', + devices: 'End devices of your{lineBreak}bookmarked applications', + searchingEntities: 'Searching applications, gateways, organizations, bookmarks', + instructions: 'Use {arrowKeys} to choose, {enter} to select', + fetchingTopEntities: 'Fetching top entities…', + noTopEntities: 'Seems like you haven’t interacted with any entities yet', + noTopEntitiesSuggestion: + 'Once you created or interacted with entities, they will show up here and you can use this panel to quickly search and navigate to them.', +}) + +const categoryMap = { + [APPLICATION]: { + title: sharedMessages.applications, + }, + [GATEWAY]: { + title: sharedMessages.gateways, + }, + [ORGANIZATION]: { + title: sharedMessages.organizations, + }, + [END_DEVICE]: { + title: sharedMessages.devices, + }, + bookmarks: { + title: sharedMessages.bookmarks, + }, + recency: { + title: sharedMessages.topEntities, + }, +} + +const iconMap = { + [APPLICATION]: IconApplication, + [GATEWAY]: IconGateway, + [ORGANIZATION]: IconOrganization, + [END_DEVICE]: IconDevice, +} + +const SearchPanel = ({ + onClose, + onSelect, + topEntities, + searchResults, + inline, + onQueryChange, + searchResultsFetching, + topEntitiesFetching, + searchQuery, +}) => { + const listRef = useRef() + const [selectedIndex, setSelectedIndex] = useState(0) + const [query, setQuery] = useState('') + const debouncedQuery = useDebounce(query, 350, onQueryChange) + const { formatMessage } = useIntl() + const lastQuery = useRef('') + const isTopEntitiesMode = debouncedQuery === '' + + let items + + // When in top entities mode, or fetching search results + // transitioning from top entities mode, show the top entities. + // The second part is necessary to avoid showing old search results. + if (isTopEntitiesMode || (lastQuery.current === '' && debouncedQuery !== searchQuery)) { + items = topEntities || [] + // In all other cases, show the search results. + } else { + items = searchResults || [] + } + + const itemCount = items.reduce((acc, item) => acc + item.items.length, 0) + const noTopEntities = + !topEntities || topEntities.length === 0 || topEntities.every(item => item.items.length === 0) + + // Keep track of the last query to determine when to switch between top entities and search results. + useEffect(() => { + if (isTopEntitiesMode) { + lastQuery.current = '' + } else if (debouncedQuery === searchQuery) { + lastQuery.current = searchQuery + } + }, [isTopEntitiesMode, debouncedQuery, searchQuery]) + + // Reset selected index when search results change or when switching modes. + useEffect(() => { + setSelectedIndex(0) + }, [searchResults, isTopEntitiesMode]) + + useEffect(() => { + const handleKeyDown = event => { + const listElement = listRef.current + let newIndex = selectedIndex + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + newIndex = + event.key === 'ArrowDown' + ? (selectedIndex + 1) % itemCount + : (selectedIndex - 1 + itemCount) % itemCount + setSelectedIndex(newIndex) + + const item = document.getElementById(`search-item-${newIndex}`) + if (item) { + const itemThreshold = item.clientHeight + if ( + item.offsetTop + item.clientHeight > + listElement.scrollTop + listElement.clientHeight - itemThreshold + ) { + listElement.scrollTop = + item.offsetTop + item.clientHeight - listElement.clientHeight + itemThreshold + } else if (item.offsetTop < listElement.scrollTop + itemThreshold) { + listElement.scrollTop = item.offsetTop - itemThreshold + } + } + } else if (event.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [itemCount, items, onClose, onSelect, selectedIndex]) + + const handleInputKeyDown = useCallback( + event => { + if (event.key === 'Escape') { + onClose() + } else if (event.key === 'Enter') { + // Get DOM item and simulate click. + document.getElementById(`search-item-${selectedIndex}`).click() + } + }, + [onClose, selectedIndex], + ) + + const handleQueryChange = useCallback(event => { + setQuery(event.target.value) + }, []) + + let i = 0 + + return ( +
+
+ + + +
+ + + {topEntitiesFetching && ( +
+ + + +
+ )} + {!topEntitiesFetching && noTopEntities && isTopEntitiesMode && ( +
+ +

+ +

+
+

+ +

+
+
+ )} + {!topEntitiesFetching && itemCount === 0 && !isTopEntitiesMode && ( +
+ +

+ +

+
+

+ +

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + }} /> +
  • +
+
+
+ )} + {items.reduce((acc, item, index) => { + if (item.items.length > 0) { + acc.push( +
+ +
, + ) + item.items.forEach(subitem => { + acc.push( + , + ) + }) + } + return acc + }, [])} +
+
+
+
+ + + +
+ ), + enter: , + }} + /> +
+
+ +
+
+
+ ) +} + +SearchPanel.propTypes = { + inline: PropTypes.bool, + onClose: PropTypes.func.isRequired, + onQueryChange: PropTypes.func.isRequired, + onSelect: PropTypes.func, + searchQuery: PropTypes.string.isRequired, + searchResults: PropTypes.arrayOf( + PropTypes.shape({ + category: PropTypes.string.isRequired, + items: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string, + id: PropTypes.string.isRequired, + }), + ), + }), + ).isRequired, + searchResultsFetching: PropTypes.bool.isRequired, + topEntities: PropTypes.arrayOf( + PropTypes.shape({ + category: PropTypes.string.isRequired, + source: PropTypes.string.isRequired, + items: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string, + id: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + }), + ), + }), + ).isRequired, + topEntitiesFetching: PropTypes.bool.isRequired, +} + +SearchPanel.defaultProps = { + inline: false, + onSelect: () => null, +} + +export default SearchPanel diff --git a/pkg/webui/components/search-panel/item.js b/pkg/webui/components/search-panel/item.js new file mode 100644 index 0000000000..5a01f7e59e --- /dev/null +++ b/pkg/webui/components/search-panel/item.js @@ -0,0 +1,62 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { useCallback } from 'react' +import classNames from 'classnames' + +import Link from '@ttn-lw/components/link' +import Icon from '@ttn-lw/components/icon' + +import PropTypes from '@ttn-lw/lib/prop-types' + +import style from './search-panel.styl' + +const PanelItem = ({ icon, title, path, subtitle, isFocused, index, onClick, onMouseEnter }) => { + const handleHover = useCallback(() => { + onMouseEnter(index) + }, [index, onMouseEnter]) + return ( + + +
+
{title}
+
{subtitle}
+
+ + ) +} + +PanelItem.propTypes = { + icon: PropTypes.icon.isRequired, + index: PropTypes.number.isRequired, + isFocused: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, + onMouseEnter: PropTypes.func.isRequired, + path: PropTypes.string.isRequired, + subtitle: PropTypes.message, + title: PropTypes.message.isRequired, +} + +PanelItem.defaultProps = { + subtitle: undefined, +} + +export default PanelItem diff --git a/pkg/webui/components/search-panel/looking-luke.js b/pkg/webui/components/search-panel/looking-luke.js new file mode 100644 index 0000000000..432e1252de --- /dev/null +++ b/pkg/webui/components/search-panel/looking-luke.js @@ -0,0 +1,59 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { useEffect, useState } from 'react' + +import Icon, { + IconMoodLookUp, + IconMoodLookDown, + IconMoodLookRight, + IconMoodLookLeft, + IconMoodCry, +} from '@ttn-lw/components/icon' + +import PropTypes from '@ttn-lw/lib/prop-types' + +const LookingLuke = ({ className }) => { + const [icon, setIcon] = useState(IconMoodLookUp) + + useEffect(() => { + const interval = setInterval(() => { + const icons = [ + IconMoodLookUp, + IconMoodLookDown, + IconMoodLookRight, + IconMoodLookLeft, + IconMoodCry, + ] + const randomIndex = Math.floor(Math.random() * icons.length) + setIcon(icons[randomIndex]) + }, 1500) + + return () => { + clearInterval(interval) + } + }, []) + + return +} + +LookingLuke.propTypes = { + className: PropTypes.string, +} + +LookingLuke.defaultProps = { + className: undefined, +} + +export default LookingLuke diff --git a/pkg/webui/components/search-panel/search-panel.styl b/pkg/webui/components/search-panel/search-panel.styl new file mode 100644 index 0000000000..e054627d98 --- /dev/null +++ b/pkg/webui/components/search-panel/search-panel.styl @@ -0,0 +1,126 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +.container + display: flex + flex-direction: column + max-width: 45rem + border-radius: $br.xl + box-shadow: var(--shadow-box-modal-normal) + overflow: hidden + position: absolute + z-index: $zi.modal + background-color: var(--c-bg-neutral-min) + left: 50% + top: 10rem + transform: translateX(-50%) + min-width: 35rem + + +media-query($bp.sm) + position: absolute + top: 0 + left: 0 + right: 0 + width: initial + height: initial + transform: none + min-width: initial + max-width: initial + border-radius: 0 + + .input + font-size: $fs.l + border: 0 + outline: none + width: 100% + height: 3rem + + &:focus::placeholder + visibility: hidden + + &::placeholder + font-size: $fs.l + color: var(--c-text-neutral-extralight) + + .x-out + color: var(--c-icon-neutral-normal) + margin-right: - $cs.m + padding: $cs.m + cursor: pointer + display: none + +media-query($bp.sm) + display: block + + .list + overflow-y: auto + max-height: 25.75rem + + .result-header + text-transform: uppercase + font-size: $fsv2.s + color: var(--c-text-neutral-light) + font-weight: $fw.bold + padding: $cs.m $cs.xl $cs.xs $cs.xl + line-height: 1 + + .result-item + display: flex + padding: $cs.m $cs.xl + align-items: center + line-height: 1 + gap: $cs.m + color: inherit + + &-focus + background-color: var(--c-bg-brand-light) + + .icon + color: var(--c-icon-neutral-light) + padding: .1rem + background-color: var(--c-bg-neutral-light) + border-radius: $br.s + + .footer + display: flex + flex-wrap: wrap + padding: $cs.s $cs.m + gap: $cs.s + color: var(--c-text-neutral-light) + font-size: $fs.s + align-items: center + justify-content: space-evenly + + .loading, .no-results + display: flex + justify-content: center + align-items: center + flex-direction: column + padding: $ls.m + color: var(--c-text-neutral-light) + border-top: 1px solid var(--c-border-neutral-light) + height: 25.75rem + box-sizing: border-box + + .no-results + & > div + font-size: $fs.s + width: 40% + margin: 0 auto + + ul + padding: 0 + line-height: 1 + width: fit-content + margin: 0 auto + diff --git a/pkg/webui/components/search-panel/story.js b/pkg/webui/components/search-panel/story.js new file mode 100644 index 0000000000..11c86d3529 --- /dev/null +++ b/pkg/webui/components/search-panel/story.js @@ -0,0 +1,79 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react' + +import SearchPanel from '.' + +export default { + title: 'Search Panel', + component: SearchPanel, +} + +const exampleItems = [ + { + category: 'applications', + items: [ + { + name: 'My Application', + id: 'my-application-id', + }, + { + name: 'My Second Application', + id: 'my-second-application-id', + }, + ], + }, + { + category: 'gateways', + items: [ + { + name: 'My Gateway', + id: 'my-gateway-id', + }, + { + name: 'My Second Gateway', + id: 'my-second-gateway-id', + }, + ], + }, + { + category: 'organizations', + items: [ + { + name: 'My Organization', + id: 'my-organization-id', + }, + { + name: 'My Second Organization', + id: 'my-second-organization-id', + }, + ], + }, + { + category: 'bookmarks', + items: [ + { + name: 'My Bookmark', + id: 'my-bookmark-id', + }, + { + name: 'My Second Bookmark', + id: 'my-second-bookmark-id', + }, + ], + }, +] + +export const Default = () => diff --git a/pkg/webui/styles/main.styl b/pkg/webui/styles/main.styl index 20629514a5..ecb530ab78 100644 --- a/pkg/webui/styles/main.styl +++ b/pkg/webui/styles/main.styl @@ -28,6 +28,7 @@ body -webkit-font-smoothing: antialiased -moz-osx-font-smoothing: grayscale background: var(--c-bg-neutral-min) + scrollbar-color: var(--c-border-neutral-normal) var(--c-bg-neutral-min) :global(#app) height: 100% diff --git a/pkg/webui/styles/variables/tokens.styl b/pkg/webui/styles/variables/tokens.styl index a69896ea78..6a402b2ec8 100644 --- a/pkg/webui/styles/variables/tokens.styl +++ b/pkg/webui/styles/variables/tokens.styl @@ -164,6 +164,7 @@ $tokens = { 'box-warning-normal': 0 0 3px 2px rgba(219, 118, 0,.2), // Shadow for focused inputs and other elements that have errors. 'box-panel-normal': 0px 1px 5px 0px rgba(0, 0, 0, .09), 'box-button-normal': 0 1px 2px 0 rgba(0, 0, 0, .05), + 'box-modal-normal': 0px 4px 35px 0px rgba(0, 0, 0, .25), }, }, From bb433c9332c803639cdddd2cf030b2e930ef1a9a Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Wed, 17 Apr 2024 14:53:16 +0200 Subject: [PATCH 03/16] console,account: Add search panel container --- .../console/containers/search-panel/index.js | 117 ++++++++++++++++++ .../containers/search-panel/search-panel.styl | 25 ++++ 2 files changed, 142 insertions(+) create mode 100644 pkg/webui/console/containers/search-panel/index.js create mode 100644 pkg/webui/console/containers/search-panel/search-panel.styl diff --git a/pkg/webui/console/containers/search-panel/index.js b/pkg/webui/console/containers/search-panel/index.js new file mode 100644 index 0000000000..c43a17a9b0 --- /dev/null +++ b/pkg/webui/console/containers/search-panel/index.js @@ -0,0 +1,117 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { useCallback, useState, useEffect } from 'react' +import DOM from 'react-dom' +import FocusLock from 'react-focus-lock' +import { RemoveScroll } from 'react-remove-scroll' +import { useDispatch, useSelector } from 'react-redux' + +import SearchPanel from '@ttn-lw/components/search-panel' + +import PropTypes from '@ttn-lw/lib/prop-types' +import useRequest from '@ttn-lw/lib/hooks/use-request' +import attachPromise from '@ttn-lw/lib/store/actions/attach-promise' + +import { getGlobalSearchResults, setSearchOpen } from '@console/store/actions/search' +import { getTopEntities } from '@console/store/actions/top-entities' + +import { + selectSearchResults, + selectSearchQuery, + selectIsSearchOpen, +} from '@console/store/selectors/search' + +import style from './search-panel.styl' + +const SearchPanelManager = () => { + const isSearchOpen = useSelector(state => selectIsSearchOpen(state)) + const dispatch = useDispatch() + + // Add a handler for the Command+K keyboard shortcut. + useEffect(() => { + const handleSlashKey = event => { + if (event.key === '/' || (event.key === 'k' && (event.metaKey || event.ctrlKey))) { + dispatch(setSearchOpen(true)) + } + } + + window.addEventListener('keydown', handleSlashKey) + + return () => window.removeEventListener('keydown', handleSlashKey) + }, [dispatch]) + + const handleClose = useCallback(() => { + dispatch(setSearchOpen(false)) + }, [dispatch]) + + if (!isSearchOpen) { + return null + } + + return DOM.createPortal( + , + document.getElementById('modal-container'), + ) +} + +const SearchPanelInner = ({ onClose }) => { + const dispatch = useDispatch() + const searchResults = useSelector(selectSearchResults) + const searchQuery = useSelector(selectSearchQuery) + const [searchResultsFetching, setSearchResultsFetching] = useState(false) + const [searchResultsError, setSearchResultsError] = useState() + + const [topItemsFetching, topItemsError, topEntities] = useRequest(getTopEntities()) + + const handleQueryChange = useCallback( + async query => { + if (query) { + try { + setSearchResultsFetching(true) + await dispatch(attachPromise(getGlobalSearchResults(query))) + } catch (error) { + setSearchResultsError(error) + } finally { + setSearchResultsFetching(false) + } + } + }, + [dispatch], + ) + + return ( + + +
+ + + + ) +} + +SearchPanelInner.propTypes = { + onClose: PropTypes.func.isRequired, +} + +export default SearchPanelManager diff --git a/pkg/webui/console/containers/search-panel/search-panel.styl b/pkg/webui/console/containers/search-panel/search-panel.styl new file mode 100644 index 0000000000..8da866da85 --- /dev/null +++ b/pkg/webui/console/containers/search-panel/search-panel.styl @@ -0,0 +1,25 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +.shadow + position: fixed + width: 100vw + height: 100vh + top: 0 + bottom: 0 + left: 0 + right:0 + background: rgba(0, 0, 0, .5) + z-index: $zi.modal + From 8198b45a3d3043eafd1085d2c60957a869bef431 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Wed, 17 Apr 2024 14:54:00 +0200 Subject: [PATCH 04/16] dev: Include tokens in storybook --- config/storybook/preview.js | 1 + 1 file changed, 1 insertion(+) diff --git a/config/storybook/preview.js b/config/storybook/preview.js index 4978a5b16f..dbcc1833b6 100644 --- a/config/storybook/preview.js +++ b/config/storybook/preview.js @@ -26,6 +26,7 @@ import backendMessages from '@ttn-lw/locales/.backend/en.json' import '../../pkg/webui/styles/main.styl' import '../../pkg/webui/styles/utilities/general.styl' import '../../pkg/webui/styles/utilities/spacing.styl' +import '../../pkg/webui/styles/utilities/tokens.styl' import 'focus-visible/dist/focus-visible' import createStore from './store' import Center from './center' From ae5ca45bec574cc58da6066392a96c07aae540ae Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Mon, 22 Apr 2024 11:11:23 +0200 Subject: [PATCH 05/16] console: Add search store logic --- pkg/webui/console/store/actions/search.js | 29 +++++++ .../console/store/middleware/logics/index.js | 2 + .../console/store/middleware/logics/search.js | 77 +++++++++++++++++++ pkg/webui/console/store/reducers/index.js | 4 +- pkg/webui/console/store/reducers/search.js | 33 ++++++++ pkg/webui/console/store/selectors/search.js | 18 +++++ 6 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 pkg/webui/console/store/actions/search.js create mode 100644 pkg/webui/console/store/middleware/logics/search.js create mode 100644 pkg/webui/console/store/reducers/search.js create mode 100644 pkg/webui/console/store/selectors/search.js diff --git a/pkg/webui/console/store/actions/search.js b/pkg/webui/console/store/actions/search.js new file mode 100644 index 0000000000..d39f6f4bb6 --- /dev/null +++ b/pkg/webui/console/store/actions/search.js @@ -0,0 +1,29 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import createRequestActions from '@ttn-lw/lib/store/actions/create-request-actions' + +export const GET_GLOBAL_SEARCH_RESULTS_BASE = 'GET_GLOBAL_SEARCH_RESULTS' +export const [ + { + request: GET_GLOBAL_SEARCH_RESULTS, + success: GET_GLOBAL_SEARCH_RESULTS_SUCCESS, + failure: GET_GLOBAL_SEARCH_RESULTS_FAILURE, + }, + { + request: getGlobalSearchResults, + success: getGlobalSearchResultsSuccess, + failure: getGlobalSearchResultsFailure, + }, +] = createRequestActions(GET_GLOBAL_SEARCH_RESULTS_BASE, (query, selector) => ({ query, selector })) diff --git a/pkg/webui/console/store/middleware/logics/index.js b/pkg/webui/console/store/middleware/logics/index.js index 8a0e5d27b8..e0a88b00ec 100644 --- a/pkg/webui/console/store/middleware/logics/index.js +++ b/pkg/webui/console/store/middleware/logics/index.js @@ -38,6 +38,7 @@ import qrCodeGenerator from './qr-code-generator' import searchAccounts from './search-accounts' import notifications from './notifications' import userPreferences from './user-preferences' +import search from './search' export default [ ...status, @@ -65,4 +66,5 @@ export default [ ...searchAccounts, ...notifications, ...userPreferences, + ...search, ] diff --git a/pkg/webui/console/store/middleware/logics/search.js b/pkg/webui/console/store/middleware/logics/search.js new file mode 100644 index 0000000000..b9ff15e46e --- /dev/null +++ b/pkg/webui/console/store/middleware/logics/search.js @@ -0,0 +1,77 @@ +// Copyright © 2022 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import tts from '@console/api/tts' + +import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' +import { getApplicationId, getGatewayId, getOrganizationId } from '@ttn-lw/lib/selectors/id' + +import * as search from '@console/store/actions/search' + +const getGlobalSearchResults = createRequestLogic({ + type: search.GET_GLOBAL_SEARCH_RESULTS, + process: async ({ action }) => { + const { query } = action.payload + const params = { + page: 1, + limit: 10, + query, + order: undefined, + deleted: false, + } + + const responses = await Promise.all([ + tts.Applications.search(params, ['name']), + tts.Gateways.search(params, ['name']), + tts.Organizations.search(params, ['name']), + ]) + + const results = [ + { + category: 'applications', + items: responses[0].applications.map(app => ({ + id: getApplicationId(app), + path: `/applications/${getApplicationId(app)}`, + ...app, + })), + totalCount: responses[0].totalCount, + }, + { + category: 'gateways', + items: responses[1].gateways.map(gateway => ({ + id: getGatewayId(gateway), + path: `/gateways/${getGatewayId(gateway)}`, + ...gateway, + })), + totalCount: responses[1].totalCount, + }, + { + category: 'organizations', + items: responses[2].organizations.map(org => ({ + id: getOrganizationId(org), + path: `/organizations/${getOrganizationId(org)}`, + ...org, + })), + totalCount: responses[2].totalCount, + }, + ] + + return { + query, + results, + } + }, +}) + +export default [getGlobalSearchResults] diff --git a/pkg/webui/console/store/reducers/index.js b/pkg/webui/console/store/reducers/index.js index 52a529420b..8ce95dd113 100644 --- a/pkg/webui/console/store/reducers/index.js +++ b/pkg/webui/console/store/reducers/index.js @@ -1,4 +1,4 @@ -// Copyright © 2019 The Things Network Foundation, The Things Industries B.V. +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -65,6 +65,7 @@ import packetBroker from './packet-broker' import ns from './network-server' import notifications from './notifications' import userPreferences from './user-preferences' +import search from './search' export default combineReducers({ user, @@ -125,4 +126,5 @@ export default combineReducers({ searchAccounts, notifications, userPreferences, + search, }) diff --git a/pkg/webui/console/store/reducers/search.js b/pkg/webui/console/store/reducers/search.js new file mode 100644 index 0000000000..374093f675 --- /dev/null +++ b/pkg/webui/console/store/reducers/search.js @@ -0,0 +1,33 @@ +// Copyright © 2022 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { handleActions } from 'redux-actions' + +import { GET_GLOBAL_SEARCH_RESULTS_SUCCESS } from '@console/store/actions/search' + +const defaultState = { + results: [], + query: '', +} + +export default handleActions( + { + [GET_GLOBAL_SEARCH_RESULTS_SUCCESS]: (state, { payload: { query, results } }) => ({ + ...state, + results, + query, + }), + }, + defaultState, +) diff --git a/pkg/webui/console/store/selectors/search.js b/pkg/webui/console/store/selectors/search.js new file mode 100644 index 0000000000..f868747988 --- /dev/null +++ b/pkg/webui/console/store/selectors/search.js @@ -0,0 +1,18 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const selectSearchStore = state => state.search + +export const selectSearchResults = state => selectSearchStore(state).results +export const selectSearchQuery = state => selectSearchStore(state).query From b811b817f502a473654ea052865fa7a3f4829f1a Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 23 Apr 2024 15:13:19 +0200 Subject: [PATCH 06/16] console: Add top entities store logic --- .../console/store/actions/top-entities.js | 25 ++++++ pkg/webui/console/store/middleware/index.js | 82 +++++++++++++++++++ .../store/middleware/logics/applications.js | 3 + .../store/middleware/logics/devices.js | 4 + .../store/middleware/logics/gateways.js | 3 + .../console/store/middleware/logics/index.js | 2 + .../store/middleware/logics/organizations.js | 3 + .../store/middleware/logics/top-entities.js | 32 ++++++++ pkg/webui/console/store/reducers/index.js | 6 +- .../console/store/reducers/top-entities.js | 31 +++++++ .../console/store/selectors/top-entities.js | 17 ++++ 11 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 pkg/webui/console/store/actions/top-entities.js create mode 100644 pkg/webui/console/store/middleware/index.js create mode 100644 pkg/webui/console/store/middleware/logics/top-entities.js create mode 100644 pkg/webui/console/store/reducers/top-entities.js create mode 100644 pkg/webui/console/store/selectors/top-entities.js diff --git a/pkg/webui/console/store/actions/top-entities.js b/pkg/webui/console/store/actions/top-entities.js new file mode 100644 index 0000000000..9779a83724 --- /dev/null +++ b/pkg/webui/console/store/actions/top-entities.js @@ -0,0 +1,25 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import createRequestActions from '@ttn-lw/lib/store/actions/create-request-actions' + +export const GET_TOP_ENTITIES_BASE = 'GET_TOP_ENTITIES' +export const [ + { + request: GET_TOP_ENTITIES, + success: GET_TOP_ENTITIES_SUCCESS, + failure: GET_TOP_ENTITIES_FAILURE, + }, + { request: getTopEntities, success: getTopEntitiesSuccess, failure: getTopEntitiesFailure }, +] = createRequestActions(GET_TOP_ENTITIES_BASE, params => ({ ...params })) diff --git a/pkg/webui/console/store/middleware/index.js b/pkg/webui/console/store/middleware/index.js new file mode 100644 index 0000000000..66e3db11c2 --- /dev/null +++ b/pkg/webui/console/store/middleware/index.js @@ -0,0 +1,82 @@ +// Copyright © 2019 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import status from '@ttn-lw/lib/store/logics/status' + +import user from './logout' +import users from './users' +import init from './init' +import applications from './applications' +import collaborators from './collaborators' +import claim from './claim' +import devices from './devices' +import gateways from './gateways' +import configuration from './configuration' +import organizations from './organizations' +import js from './join-server' +import apiKeys from './api-keys' +import webhooks from './webhooks' +import pubsubs from './pubsubs' +import applicationPackages from './application-packages' +import is from './identity-server' +import as from './application-server' +import deviceRepository from './device-repository' +import packetBroker from './packet-broker' +import networkServer from './network-server' +import qrCodeGenerator from './qr-code-generator' +import searchAccounts from './search-accounts' +import notifications from './notifications' +import userPreferences from './user-preferences' +import topEntities from './logics/top-entities' + +import topEntities from './logics/top-entities' + +import topEntities from './logics/top-entities' + +import search from './search' + +export default [ + ...status, + ...user, + ...users, + ...init, + ...applications, + ...claim, + ...devices, + ...gateways, + ...configuration, + ...organizations, + ...js, + ...apiKeys, + ...webhooks, + ...pubsubs, + ...applicationPackages, + ...is, + ...as, + ...deviceRepository, + ...packetBroker, + ...collaborators, + ...networkServer, + ...qrCodeGenerator, + ...searchAccounts, + ...notifications, + ...userPreferences, + ...search, + ...top-entities, + + ...topEntities, + + ...topEntities, + +] diff --git a/pkg/webui/console/store/middleware/logics/applications.js b/pkg/webui/console/store/middleware/logics/applications.js index a26804309f..24baf1bffb 100644 --- a/pkg/webui/console/store/middleware/logics/applications.js +++ b/pkg/webui/console/store/middleware/logics/applications.js @@ -17,6 +17,8 @@ import tts from '@console/api/tts' import { isNotFoundError, isConflictError } from '@ttn-lw/lib/errors/utils' import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' +import { trackEntityAccess } from '@console/lib/frequently-visited-entities' + import * as applications from '@console/store/actions/applications' import * as link from '@console/store/actions/link' @@ -39,6 +41,7 @@ const getApplicationLogic = createRequestLogic({ meta: { selector }, } = action const app = await tts.Applications.getById(id, selector) + trackEntityAccess('app', id) dispatch(applications.startApplicationEventsStream(id)) return app diff --git a/pkg/webui/console/store/middleware/logics/devices.js b/pkg/webui/console/store/middleware/logics/devices.js index 97cbe857f2..1ebffa87f2 100644 --- a/pkg/webui/console/store/middleware/logics/devices.js +++ b/pkg/webui/console/store/middleware/logics/devices.js @@ -20,6 +20,9 @@ import tts from '@console/api/tts' import toast from '@ttn-lw/components/toast' import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' +import { combineDeviceIds } from '@ttn-lw/lib/selectors/id' + +import { trackEntityAccess } from '@console/lib/frequently-visited-entities' import * as devices from '@console/store/actions/devices' import * as deviceTemplateFormats from '@console/store/actions/device-template-formats' @@ -49,6 +52,7 @@ const getDeviceLogic = createRequestLogic({ meta: { selector }, } = action const dev = await tts.Applications.Devices.getById(appId, deviceId, selector) + trackEntityAccess('dev', combineDeviceIds(appId, deviceId)) dispatch(devices.startDeviceEventsStream(dev.ids)) return dev diff --git a/pkg/webui/console/store/middleware/logics/gateways.js b/pkg/webui/console/store/middleware/logics/gateways.js index b8665e391a..5b53252572 100644 --- a/pkg/webui/console/store/middleware/logics/gateways.js +++ b/pkg/webui/console/store/middleware/logics/gateways.js @@ -22,6 +22,8 @@ import { getGatewayId } from '@ttn-lw/lib/selectors/id' import getHostFromUrl from '@ttn-lw/lib/host-from-url' import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' +import { trackEntityAccess } from '@console/lib/frequently-visited-entities' + import * as gateways from '@console/store/actions/gateways' import { @@ -47,6 +49,7 @@ const getGatewayLogic = createRequestLogic({ const { id = {} } = payload const selector = meta.selector || '' const gtw = await tts.Gateways.getById(id, selector) + trackEntityAccess('gtw', id) dispatch(gateways.startGatewayEventsStream(id)) return gtw diff --git a/pkg/webui/console/store/middleware/logics/index.js b/pkg/webui/console/store/middleware/logics/index.js index e0a88b00ec..765db1dac5 100644 --- a/pkg/webui/console/store/middleware/logics/index.js +++ b/pkg/webui/console/store/middleware/logics/index.js @@ -39,6 +39,7 @@ import searchAccounts from './search-accounts' import notifications from './notifications' import userPreferences from './user-preferences' import search from './search' +import topEntities from './top-entities' export default [ ...status, @@ -67,4 +68,5 @@ export default [ ...notifications, ...userPreferences, ...search, + ...topEntities, ] diff --git a/pkg/webui/console/store/middleware/logics/organizations.js b/pkg/webui/console/store/middleware/logics/organizations.js index 65fa2968e2..b111c5d020 100644 --- a/pkg/webui/console/store/middleware/logics/organizations.js +++ b/pkg/webui/console/store/middleware/logics/organizations.js @@ -17,6 +17,8 @@ import tts from '@console/api/tts' import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' import { getOrganizationId } from '@ttn-lw/lib/selectors/id' +import { trackEntityAccess } from '@console/lib/frequently-visited-entities' + import * as organizations from '@console/store/actions/organizations' import { selectUserId } from '@console/store/selectors/logout' @@ -31,6 +33,7 @@ const getOrganizationLogic = createRequestLogic({ meta: { selector }, } = action const org = await tts.Organizations.getById(id, selector) + trackEntityAccess('org', id) dispatch(organizations.startOrganizationEventsStream(id)) return org }, diff --git a/pkg/webui/console/store/middleware/logics/top-entities.js b/pkg/webui/console/store/middleware/logics/top-entities.js new file mode 100644 index 0000000000..6e200033fb --- /dev/null +++ b/pkg/webui/console/store/middleware/logics/top-entities.js @@ -0,0 +1,32 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' +import attachPromise from '@ttn-lw/lib/store/actions/attach-promise' + +import { getTopFrequencyRecencyItems } from '@console/lib/frequently-visited-entities' + +import * as actions from '@console/store/actions/top-entities' +import { getBookmarksList } from '@console/store/actions/user-preferences' + +const getTopEntitiesLogic = createRequestLogic({ + type: actions.GET_TOP_ENTITIES, + process: async ({ action }, dispatch) => { + const topFrequencyRecencyItems = getTopFrequencyRecencyItems() + + return topFrequencyRecencyItems + }, +}) + +export default [getTopEntitiesLogic] diff --git a/pkg/webui/console/store/reducers/index.js b/pkg/webui/console/store/reducers/index.js index 8ce95dd113..13f88730ed 100644 --- a/pkg/webui/console/store/reducers/index.js +++ b/pkg/webui/console/store/reducers/index.js @@ -65,6 +65,8 @@ import packetBroker from './packet-broker' import ns from './network-server' import notifications from './notifications' import userPreferences from './user-preferences' +import topEntities from './top-entities' + import search from './search' export default combineReducers({ @@ -127,4 +129,6 @@ export default combineReducers({ notifications, userPreferences, search, -}) + topEntities, + +}) \ No newline at end of file diff --git a/pkg/webui/console/store/reducers/top-entities.js b/pkg/webui/console/store/reducers/top-entities.js new file mode 100644 index 0000000000..60e5a524f0 --- /dev/null +++ b/pkg/webui/console/store/reducers/top-entities.js @@ -0,0 +1,31 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { handleActions } from 'redux-actions' + +import { GET_TOP_ENTITIES_SUCCESS } from '@console/store/actions/top-entities' + +const defaultState = { + data: [], +} + +export default handleActions( + { + [GET_TOP_ENTITIES_SUCCESS]: (state, { payload }) => ({ + ...state, + data: payload, + }), + }, + defaultState, +) diff --git a/pkg/webui/console/store/selectors/top-entities.js b/pkg/webui/console/store/selectors/top-entities.js new file mode 100644 index 0000000000..021ad8a136 --- /dev/null +++ b/pkg/webui/console/store/selectors/top-entities.js @@ -0,0 +1,17 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const selectTopEntitiesStore = state => state.topEntities + +export const selectTopEntitiesData = state => selectTopEntitiesStore(state).data From 8d17cf3b940b4bd1a6d0fd29d33841879122cd9c Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 23 Apr 2024 15:14:59 +0200 Subject: [PATCH 07/16] console: Add recency frequency lib --- .../lib/frequently-visited-entities.js | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 pkg/webui/console/lib/frequently-visited-entities.js diff --git a/pkg/webui/console/lib/frequently-visited-entities.js b/pkg/webui/console/lib/frequently-visited-entities.js new file mode 100644 index 0000000000..29b9327370 --- /dev/null +++ b/pkg/webui/console/lib/frequently-visited-entities.js @@ -0,0 +1,80 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This module keeps track of the entities that are frequently visited by the user +// and stores it in the local storage. We use the Recency and Frequency model to +// determine the entities that are frequently visited by the user. The model is +// based on the assumption that the more frequently and recently an entity is +// visited, the more important it is to the user. + +const FREQUENTLY_VISITED_ENTITIES_KEY = 'frequentlyVisitedEntities' +const MAX_FREQUENTLY_VISITED_ENTITIES = 5 + +const getFrequentlyVisitedEntities = () => + JSON.parse(localStorage.getItem(FREQUENTLY_VISITED_ENTITIES_KEY)) || {} + +const setFrequentlyVisitedEntities = entities => { + localStorage.setItem(FREQUENTLY_VISITED_ENTITIES_KEY, JSON.stringify(entities)) +} + +const getTypeAndId = item => { + const [entityType, entityId] = item.key.split(':') + + return { entityType, entityId } +} + +const trackEntityAccess = (entityType, entityId) => { + const storedData = getFrequentlyVisitedEntities() + const entityKey = `${entityType}:${entityId}` + const entity = storedData[entityKey] + + if (entity) { + storedData[entityKey] = { + ...entity, + frequency: entity.frequency + 1, + lastAccessed: Date.now(), + } + } + + if (!entity) { + storedData[entityKey] = { + frequency: 1, + lastAccessed: Date.now(), + } + } + + setFrequentlyVisitedEntities(storedData) +} + +const calculateScore = entity => { + const now = Date.now() + const recency = now - entity.lastAccessed + const decay = Math.exp(-recency / 1000) + + return entity.frequency * decay +} + +const getTopFrequencyRecencyItems = () => { + const storedData = getFrequentlyVisitedEntities() + const entities = Object.keys(storedData).map(key => ({ + key, + score: calculateScore(storedData[key]), + })) + + entities.sort((a, b) => b.score - a.score) + + return entities.slice(0, MAX_FREQUENTLY_VISITED_ENTITIES) +} + +export { trackEntityAccess, getTopFrequencyRecencyItems, getTypeAndId } From f9e9594cbac80bf3d7dc40ef1db26e6325903903 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 23 Apr 2024 15:16:11 +0200 Subject: [PATCH 08/16] console: Fix entity change bugs --- .../application-title-section/index.js | 13 ++++------ .../containers/device-title-section/index.js | 24 ++++--------------- .../containers/gateway-title-section/index.js | 12 ++++------ .../organization-title-section/index.js | 15 ++++++------ pkg/webui/console/containers/sidebar/index.js | 3 +++ .../views/application-overview/index.js | 2 +- pkg/webui/console/views/device/device.js | 2 +- pkg/webui/console/views/gateway/index.js | 4 +++- .../views/organization-overview/index.js | 2 +- 9 files changed, 30 insertions(+), 47 deletions(-) diff --git a/pkg/webui/console/containers/application-title-section/index.js b/pkg/webui/console/containers/application-title-section/index.js index 1d5ed38679..0ac08adc0a 100644 --- a/pkg/webui/console/containers/application-title-section/index.js +++ b/pkg/webui/console/containers/application-title-section/index.js @@ -29,7 +29,6 @@ import LastSeen from '@console/components/last-seen' import EntityTitleSection from '@console/components/entity-title-section' import sharedMessages from '@ttn-lw/lib/shared-messages' -import PropTypes from '@ttn-lw/lib/prop-types' import { selectCollaboratorsTotalCount } from '@ttn-lw/lib/store/selectors/collaborators' import { getCollaboratorsList } from '@ttn-lw/lib/store/actions/collaborators' @@ -44,9 +43,10 @@ import { getApiKeysList } from '@console/store/actions/api-keys' import { getApplicationDeviceCount } from '@console/store/actions/applications' import { - selectApplicationById, selectApplicationDeviceCount, selectApplicationDerivedLastSeen, + selectSelectedApplication, + selectSelectedApplicationId, } from '@console/store/selectors/applications' import { selectApiKeysTotalCount } from '@console/store/selectors/api-keys' @@ -59,13 +59,14 @@ const m = defineMessages({ const { Content } = EntityTitleSection -const ApplicationTitleSection = ({ appId }) => { +const ApplicationTitleSection = () => { + const application = useSelector(selectSelectedApplication) + const appId = useSelector(selectSelectedApplicationId) const apiKeysTotalCount = useSelector(selectApiKeysTotalCount) const collaboratorsTotalCount = useSelector(state => selectCollaboratorsTotalCount(state, { id: appId }), ) const devicesTotalCount = useSelector(state => selectApplicationDeviceCount(state, appId)) - const application = useSelector(state => selectApplicationById(state, appId)) const lastSeen = useSelector(state => selectApplicationDerivedLastSeen(state, appId)) const mayViewCollaborators = useSelector(state => checkFromState(mayViewOrEditApplicationCollaborators, state), @@ -157,8 +158,4 @@ const ApplicationTitleSection = ({ appId }) => { ) } -ApplicationTitleSection.propTypes = { - appId: PropTypes.string.isRequired, -} - export default ApplicationTitleSection diff --git a/pkg/webui/console/containers/device-title-section/index.js b/pkg/webui/console/containers/device-title-section/index.js index 4d29ec8444..357dc42bde 100644 --- a/pkg/webui/console/containers/device-title-section/index.js +++ b/pkg/webui/console/containers/device-title-section/index.js @@ -29,15 +29,15 @@ import DateTime from '@ttn-lw/lib/components/date-time' import EntityTitleSection from '@console/components/entity-title-section' import LastSeen from '@console/components/last-seen' -import PropTypes from '@ttn-lw/lib/prop-types' import sharedMessages from '@ttn-lw/lib/shared-messages' import { - selectDeviceByIds, selectDeviceDerivedAppDownlinkFrameCount, selectDeviceDerivedNwkDownlinkFrameCount, selectDeviceDerivedUplinkFrameCount, selectDeviceLastSeen, + selectSelectedCombinedDeviceId, + selectSelectedDevice, } from '@console/store/selectors/devices' const m = defineMessages({ @@ -51,9 +51,9 @@ const m = defineMessages({ const { Content } = EntityTitleSection -const DeviceTitleSection = props => { - const { appId, devId, fetching, children } = props - const device = useSelector(state => selectDeviceByIds(state, appId, devId)) +const DeviceTitleSection = () => { + const device = useSelector(selectSelectedDevice) + const [appId, devId] = useSelector(selectSelectedCombinedDeviceId).split('/') const uplinkFrameCount = useSelector(state => selectDeviceDerivedUplinkFrameCount(state, appId, devId), ) @@ -155,24 +155,10 @@ const DeviceTitleSection = props => { - {children} ) } -DeviceTitleSection.propTypes = { - appId: PropTypes.string.isRequired, - children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), - devId: PropTypes.string.isRequired, - fetching: PropTypes.bool, -} - -DeviceTitleSection.defaultProps = { - children: null, - fetching: false, -} - export default DeviceTitleSection diff --git a/pkg/webui/console/containers/gateway-title-section/index.js b/pkg/webui/console/containers/gateway-title-section/index.js index 859898ecdc..8a919478fe 100644 --- a/pkg/webui/console/containers/gateway-title-section/index.js +++ b/pkg/webui/console/containers/gateway-title-section/index.js @@ -26,7 +26,6 @@ import EntityTitleSection from '@console/components/entity-title-section' import GatewayConnection from '@console/containers/gateway-connection' import sharedMessages from '@ttn-lw/lib/shared-messages' -import PropTypes from '@ttn-lw/lib/prop-types' import { selectCollaboratorsTotalCount } from '@ttn-lw/lib/store/selectors/collaborators' import { getCollaboratorsList } from '@ttn-lw/lib/store/actions/collaborators' @@ -39,11 +38,13 @@ import { import { getApiKeysList } from '@console/store/actions/api-keys' import { selectApiKeysTotalCount } from '@console/store/selectors/api-keys' -import { selectGatewayById } from '@console/store/selectors/gateways' +import { selectSelectedGateway, selectSelectedGatewayId } from '@console/store/selectors/gateways' const { Content } = EntityTitleSection -const GatewayTitleSection = ({ gtwId }) => { +const GatewayTitleSection = () => { + const gateway = useSelector(selectSelectedGateway) + const gtwId = useSelector(selectSelectedGatewayId) const apiKeysTotalCount = useSelector(selectApiKeysTotalCount) const collaboratorsTotalCount = useSelector(state => selectCollaboratorsTotalCount(state, { id: gtwId }), @@ -52,7 +53,6 @@ const GatewayTitleSection = ({ gtwId }) => { checkFromState(mayViewOrEditGatewayCollaborators, state), ) const mayViewApiKeys = useSelector(state => checkFromState(mayViewOrEditGatewayApiKeys, state)) - const gateway = useSelector(state => selectGatewayById(state, gtwId)) const bottomBarLeft = const bottomBarRight = ( @@ -107,8 +107,4 @@ const GatewayTitleSection = ({ gtwId }) => { ) } -GatewayTitleSection.propTypes = { - gtwId: PropTypes.string.isRequired, -} - export default GatewayTitleSection diff --git a/pkg/webui/console/containers/organization-title-section/index.js b/pkg/webui/console/containers/organization-title-section/index.js index 4dfb5e3feb..cd98a6ecaa 100644 --- a/pkg/webui/console/containers/organization-title-section/index.js +++ b/pkg/webui/console/containers/organization-title-section/index.js @@ -24,7 +24,6 @@ import RequireRequest from '@ttn-lw/lib/components/require-request' import EntityTitleSection from '@console/components/entity-title-section' import sharedMessages from '@ttn-lw/lib/shared-messages' -import PropTypes from '@ttn-lw/lib/prop-types' import { selectCollaboratorsTotalCount } from '@ttn-lw/lib/store/selectors/collaborators' import { getCollaboratorsList } from '@ttn-lw/lib/store/actions/collaborators' @@ -37,11 +36,16 @@ import { import { getApiKeysList } from '@console/store/actions/api-keys' import { selectApiKeysTotalCount } from '@console/store/selectors/api-keys' -import { selectOrganizationById } from '@console/store/selectors/organizations' +import { + selectSelectedOrganization, + selectSelectedOrganizationId, +} from '@console/store/selectors/organizations' const { Content } = EntityTitleSection -const OrganizationTitleSection = ({ orgId }) => { +const OrganizationTitleSection = () => { + const organization = useSelector(selectSelectedOrganization) + const orgId = useSelector(selectSelectedOrganizationId) const apiKeysTotalCount = useSelector(selectApiKeysTotalCount) const collaboratorsTotalCount = useSelector(state => selectCollaboratorsTotalCount(state, { id: orgId }), @@ -52,7 +56,6 @@ const OrganizationTitleSection = ({ orgId }) => { const mayViewApiKeys = useSelector(state => checkFromState(mayViewOrEditOrganizationApiKeys, state), ) - const organization = useSelector(state => selectOrganizationById(state, orgId)) const loadData = useCallback( async dispatch => { @@ -98,8 +101,4 @@ const OrganizationTitleSection = ({ orgId }) => { ) } -OrganizationTitleSection.propTypes = { - orgId: PropTypes.string.isRequired, -} - export default OrganizationTitleSection diff --git a/pkg/webui/console/containers/sidebar/index.js b/pkg/webui/console/containers/sidebar/index.js index c26b834174..46d3b787f5 100644 --- a/pkg/webui/console/containers/sidebar/index.js +++ b/pkg/webui/console/containers/sidebar/index.js @@ -23,6 +23,8 @@ import SideFooter from '@ttn-lw/components/sidebar/side-footer' import PropTypes from '@ttn-lw/lib/prop-types' +import SearchPanelManager from '../search-panel' + import SidebarNavigation from './navigation' import SidebarContext from './context' import SideHeader from './header' @@ -81,6 +83,7 @@ const Sidebar = ({ isDrawerOpen, onDrawerCloseClick }) => {
+ ) } diff --git a/pkg/webui/console/views/application-overview/index.js b/pkg/webui/console/views/application-overview/index.js index 0b38d60e3f..539d5a706e 100644 --- a/pkg/webui/console/views/application-overview/index.js +++ b/pkg/webui/console/views/application-overview/index.js @@ -74,7 +74,7 @@ const ApplicationOverview = () => {
- +
diff --git a/pkg/webui/console/views/device/device.js b/pkg/webui/console/views/device/device.js index df01b53693..28643bef8a 100644 --- a/pkg/webui/console/views/device/device.js +++ b/pkg/webui/console/views/device/device.js @@ -113,7 +113,7 @@ const Device = () => {
- +
diff --git a/pkg/webui/console/views/gateway/index.js b/pkg/webui/console/views/gateway/index.js index 9e9eb5139a..f4d71bde09 100644 --- a/pkg/webui/console/views/gateway/index.js +++ b/pkg/webui/console/views/gateway/index.js @@ -83,7 +83,9 @@ const Gateway = () => { const hasGateway = Boolean(gateway) return ( - {hasGateway && } + + {hasGateway && } + ) } diff --git a/pkg/webui/console/views/organization-overview/index.js b/pkg/webui/console/views/organization-overview/index.js index 4fbbf2080d..1692c54e19 100644 --- a/pkg/webui/console/views/organization-overview/index.js +++ b/pkg/webui/console/views/organization-overview/index.js @@ -62,7 +62,7 @@ const Overview = () => {
- +
From bb4f977a3d92394b6c4675c74a3d4e6f36519d40 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 14 May 2024 15:31:58 +0200 Subject: [PATCH 09/16] console: Add store logic for search --- pkg/webui/console/store/actions/search.js | 5 + pkg/webui/console/store/middleware/index.js | 82 --------- .../store/middleware/logics/applications.js | 3 +- .../store/middleware/logics/devices.js | 3 +- .../store/middleware/logics/gateways.js | 3 +- .../store/middleware/logics/organizations.js | 3 +- .../console/store/middleware/logics/search.js | 54 +++++- .../store/middleware/logics/top-entities.js | 159 +++++++++++++++++- pkg/webui/console/store/reducers/index.js | 4 +- pkg/webui/console/store/reducers/search.js | 7 +- .../console/store/reducers/top-entities.js | 2 + pkg/webui/console/store/selectors/search.js | 1 + .../console/store/selectors/top-entities.js | 5 + 13 files changed, 228 insertions(+), 103 deletions(-) delete mode 100644 pkg/webui/console/store/middleware/index.js diff --git a/pkg/webui/console/store/actions/search.js b/pkg/webui/console/store/actions/search.js index d39f6f4bb6..e1386a0e4e 100644 --- a/pkg/webui/console/store/actions/search.js +++ b/pkg/webui/console/store/actions/search.js @@ -12,8 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { createAction } from 'redux-actions' + import createRequestActions from '@ttn-lw/lib/store/actions/create-request-actions' +export const SET_SEARCH_OPEN = 'SET_SEARCH_OPEN' +export const setSearchOpen = createAction(SET_SEARCH_OPEN, searchOpen => ({ searchOpen })) + export const GET_GLOBAL_SEARCH_RESULTS_BASE = 'GET_GLOBAL_SEARCH_RESULTS' export const [ { diff --git a/pkg/webui/console/store/middleware/index.js b/pkg/webui/console/store/middleware/index.js deleted file mode 100644 index 66e3db11c2..0000000000 --- a/pkg/webui/console/store/middleware/index.js +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright © 2019 The Things Network Foundation, The Things Industries B.V. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import status from '@ttn-lw/lib/store/logics/status' - -import user from './logout' -import users from './users' -import init from './init' -import applications from './applications' -import collaborators from './collaborators' -import claim from './claim' -import devices from './devices' -import gateways from './gateways' -import configuration from './configuration' -import organizations from './organizations' -import js from './join-server' -import apiKeys from './api-keys' -import webhooks from './webhooks' -import pubsubs from './pubsubs' -import applicationPackages from './application-packages' -import is from './identity-server' -import as from './application-server' -import deviceRepository from './device-repository' -import packetBroker from './packet-broker' -import networkServer from './network-server' -import qrCodeGenerator from './qr-code-generator' -import searchAccounts from './search-accounts' -import notifications from './notifications' -import userPreferences from './user-preferences' -import topEntities from './logics/top-entities' - -import topEntities from './logics/top-entities' - -import topEntities from './logics/top-entities' - -import search from './search' - -export default [ - ...status, - ...user, - ...users, - ...init, - ...applications, - ...claim, - ...devices, - ...gateways, - ...configuration, - ...organizations, - ...js, - ...apiKeys, - ...webhooks, - ...pubsubs, - ...applicationPackages, - ...is, - ...as, - ...deviceRepository, - ...packetBroker, - ...collaborators, - ...networkServer, - ...qrCodeGenerator, - ...searchAccounts, - ...notifications, - ...userPreferences, - ...search, - ...top-entities, - - ...topEntities, - - ...topEntities, - -] diff --git a/pkg/webui/console/store/middleware/logics/applications.js b/pkg/webui/console/store/middleware/logics/applications.js index 24baf1bffb..fce6608da8 100644 --- a/pkg/webui/console/store/middleware/logics/applications.js +++ b/pkg/webui/console/store/middleware/logics/applications.js @@ -13,6 +13,7 @@ // limitations under the License. import tts from '@console/api/tts' +import { APPLICATION } from '@console/constants/entities' import { isNotFoundError, isConflictError } from '@ttn-lw/lib/errors/utils' import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' @@ -41,7 +42,7 @@ const getApplicationLogic = createRequestLogic({ meta: { selector }, } = action const app = await tts.Applications.getById(id, selector) - trackEntityAccess('app', id) + trackEntityAccess(APPLICATION, id) dispatch(applications.startApplicationEventsStream(id)) return app diff --git a/pkg/webui/console/store/middleware/logics/devices.js b/pkg/webui/console/store/middleware/logics/devices.js index 1ebffa87f2..f476be81fa 100644 --- a/pkg/webui/console/store/middleware/logics/devices.js +++ b/pkg/webui/console/store/middleware/logics/devices.js @@ -16,6 +16,7 @@ import { createLogic } from 'redux-logic' import { defineMessage } from 'react-intl' import tts from '@console/api/tts' +import { END_DEVICE } from '@console/constants/entities' import toast from '@ttn-lw/components/toast' @@ -52,7 +53,7 @@ const getDeviceLogic = createRequestLogic({ meta: { selector }, } = action const dev = await tts.Applications.Devices.getById(appId, deviceId, selector) - trackEntityAccess('dev', combineDeviceIds(appId, deviceId)) + trackEntityAccess(END_DEVICE, combineDeviceIds(appId, deviceId)) dispatch(devices.startDeviceEventsStream(dev.ids)) return dev diff --git a/pkg/webui/console/store/middleware/logics/gateways.js b/pkg/webui/console/store/middleware/logics/gateways.js index 5b53252572..1893bf730e 100644 --- a/pkg/webui/console/store/middleware/logics/gateways.js +++ b/pkg/webui/console/store/middleware/logics/gateways.js @@ -15,6 +15,7 @@ import { createLogic } from 'redux-logic' import tts from '@console/api/tts' +import { GATEWAY } from '@console/constants/entities' import sharedMessages from '@ttn-lw/lib/shared-messages' import { selectGsConfig } from '@ttn-lw/lib/selectors/env' @@ -49,7 +50,7 @@ const getGatewayLogic = createRequestLogic({ const { id = {} } = payload const selector = meta.selector || '' const gtw = await tts.Gateways.getById(id, selector) - trackEntityAccess('gtw', id) + trackEntityAccess(GATEWAY, id) dispatch(gateways.startGatewayEventsStream(id)) return gtw diff --git a/pkg/webui/console/store/middleware/logics/organizations.js b/pkg/webui/console/store/middleware/logics/organizations.js index b111c5d020..0a6b139c81 100644 --- a/pkg/webui/console/store/middleware/logics/organizations.js +++ b/pkg/webui/console/store/middleware/logics/organizations.js @@ -13,6 +13,7 @@ // limitations under the License. import tts from '@console/api/tts' +import { ORGANIZATION } from '@console/constants/entities' import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' import { getOrganizationId } from '@ttn-lw/lib/selectors/id' @@ -33,7 +34,7 @@ const getOrganizationLogic = createRequestLogic({ meta: { selector }, } = action const org = await tts.Organizations.getById(id, selector) - trackEntityAccess('org', id) + trackEntityAccess(ORGANIZATION, id) dispatch(organizations.startOrganizationEventsStream(id)) return org }, diff --git a/pkg/webui/console/store/middleware/logics/search.js b/pkg/webui/console/store/middleware/logics/search.js index b9ff15e46e..8ed0592a90 100644 --- a/pkg/webui/console/store/middleware/logics/search.js +++ b/pkg/webui/console/store/middleware/logics/search.js @@ -13,15 +13,23 @@ // limitations under the License. import tts from '@console/api/tts' +import { APPLICATION, END_DEVICE, GATEWAY, ORGANIZATION } from '@console/constants/entities' import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' -import { getApplicationId, getGatewayId, getOrganizationId } from '@ttn-lw/lib/selectors/id' +import { + getApplicationId, + getDeviceId, + getGatewayId, + getOrganizationId, +} from '@ttn-lw/lib/selectors/id' import * as search from '@console/store/actions/search' +import { selectConcatenatedTopEntitiesByType } from '@console/store/selectors/top-entities' + const getGlobalSearchResults = createRequestLogic({ type: search.GET_GLOBAL_SEARCH_RESULTS, - process: async ({ action }) => { + process: async ({ getState, action }) => { const { query } = action.payload const params = { page: 1, @@ -31,39 +39,67 @@ const getGlobalSearchResults = createRequestLogic({ deleted: false, } + const topApplications = selectConcatenatedTopEntitiesByType(getState(), APPLICATION).slice(0, 3) + const responses = await Promise.all([ tts.Applications.search(params, ['name']), + Promise.all( + topApplications.map(app => tts.Applications.Devices.search(app.id, params, ['name'])), + ), tts.Gateways.search(params, ['name']), tts.Organizations.search(params, ['name']), ]) const results = [ { - category: 'applications', + category: APPLICATION, items: responses[0].applications.map(app => ({ id: getApplicationId(app), + type: APPLICATION, path: `/applications/${getApplicationId(app)}`, ...app, })), totalCount: responses[0].totalCount, }, { - category: 'gateways', - items: responses[1].gateways.map(gateway => ({ + category: END_DEVICE, + items: responses[1] + // Combine all end devices from all applications together + .reduce( + (acc, res) => { + acc.end_devices = acc.end_devices.concat(res.end_devices) + acc.totalCount += res.totalCount + return acc + }, + { end_devices: [], totalCount: 0 }, + ) + .end_devices.map(device => ({ + id: getDeviceId(device), + type: END_DEVICE, + path: `/applications/${getApplicationId(device)}/devices/${getDeviceId(device)}`, + ...device, + })), + totalCount: responses[1].totalCount, + }, + { + category: GATEWAY, + items: responses[2].gateways.map(gateway => ({ id: getGatewayId(gateway), + type: GATEWAY, path: `/gateways/${getGatewayId(gateway)}`, ...gateway, })), - totalCount: responses[1].totalCount, + totalCount: responses[2].totalCount, }, { - category: 'organizations', - items: responses[2].organizations.map(org => ({ + category: ORGANIZATION, + items: responses[3].organizations.map(org => ({ id: getOrganizationId(org), + type: ORGANIZATION, path: `/organizations/${getOrganizationId(org)}`, ...org, })), - totalCount: responses[2].totalCount, + totalCount: responses[3].totalCount, }, ] diff --git a/pkg/webui/console/store/middleware/logics/top-entities.js b/pkg/webui/console/store/middleware/logics/top-entities.js index 6e200033fb..36fa25bf10 100644 --- a/pkg/webui/console/store/middleware/logics/top-entities.js +++ b/pkg/webui/console/store/middleware/logics/top-entities.js @@ -12,20 +12,171 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { APPLICATION, END_DEVICE, GATEWAY, ORGANIZATION } from '@console/constants/entities' + import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic' import attachPromise from '@ttn-lw/lib/store/actions/attach-promise' +import { + combineDeviceIds, + extractApplicationIdFromCombinedId, + extractDeviceIdFromCombinedId, +} from '@ttn-lw/lib/selectors/id' -import { getTopFrequencyRecencyItems } from '@console/lib/frequently-visited-entities' +import { getTopFrequencyRecencyItems, getTypeAndId } from '@console/lib/frequently-visited-entities' import * as actions from '@console/store/actions/top-entities' -import { getBookmarksList } from '@console/store/actions/user-preferences' +import { getAllBookmarks } from '@console/store/actions/user-preferences' +import { getApplicationsList } from '@console/store/actions/applications' +import { getOrganizationsList } from '@console/store/actions/organizations' +import { getGatewaysList } from '@console/store/actions/gateways' +import { getDevicesList } from '@console/store/actions/devices' + +import { selectUserId } from '@account/store/selectors/user' +import { selectApplicationById } from '@console/store/selectors/applications' +import { selectGatewayById } from '@console/store/selectors/gateways' +import { selectOrganizationById } from '@console/store/selectors/organizations' +import { selectDeviceByIds } from '@console/store/selectors/devices' +import { selectTopEntitiesLastFetched } from '@console/store/selectors/top-entities' + +const MAX_ENTITIES = 15 + +const getBookmarkType = bookmark => + Object.keys(bookmark.entity_ids)[0].replace('_ids', '').toUpperCase() +const getEntityPath = (id, type) => { + switch (type) { + case APPLICATION: + return `/applications/${id}` + case END_DEVICE: + return `/applications/${extractApplicationIdFromCombinedId(id)}/devices/${extractDeviceIdFromCombinedId(id)}` + case GATEWAY: + return `/gateways/${id}` + case ORGANIZATION: + return `/organizations/${id}` + } +} +const getEntityName = (id, type, state) => { + switch (type) { + case APPLICATION: + return selectApplicationById(state, id)?.name + case END_DEVICE: + return selectDeviceByIds( + state, + extractApplicationIdFromCombinedId(id), + extractDeviceIdFromCombinedId(id), + )?.name + case GATEWAY: + return selectGatewayById(state, id)?.name + case ORGANIZATION: + return selectOrganizationById(state, id)?.name + default: + return '' + } +} +const getBookmarkEntityId = bookmark => { + const type = getBookmarkType(bookmark) + switch (type) { + case APPLICATION: + return bookmark.entity_ids.application_ids.application_id + case END_DEVICE: + return combineDeviceIds( + bookmark.entity_ids.end_device_ids.application_ids.application_id, + bookmark.entity_ids.end_device_ids.device_id, + ) + case GATEWAY: + return bookmark.entity_ids.gateway_ids.gateway_id + case ORGANIZATION: + return bookmark.entity_ids.organization_ids.organization_id + } +} const getTopEntitiesLogic = createRequestLogic({ type: actions.GET_TOP_ENTITIES, - process: async ({ action }, dispatch) => { + process: async ({ getState }, dispatch) => { + const state = getState() + const limit = 100 + const order = '-created_at' + const lastFetched = selectTopEntitiesLastFetched(state) + + // Only refetch entity names every 5 minutes. + const shouldRefetch = lastFetched && Date.now() - lastFetched < 1000 * 60 * 5 // 5 minutes + + if (!shouldRefetch) { + // Fetch 100 items of all entity types to have some initial data in the store + // to source the entity names from. This is a best effort to avoid making + // requests for every single entity. + await Promise.all([ + dispatch(attachPromise(getApplicationsList({ page: 1, limit, order }, ['name']))), + dispatch(attachPromise(getGatewaysList({ page: 1, limit, order }, ['name']))), + dispatch(attachPromise(getOrganizationsList({ page: 1, limit, order }, ['name']))), + ]) + } + + const topEntities = [] const topFrequencyRecencyItems = getTopFrequencyRecencyItems() + const userId = selectUserId(state) + + const { bookmarks } = await dispatch(attachPromise(getAllBookmarks(userId))) + + // Get the top 3 frequency recency items that are applications. + const topFrequencyRecencyApplicationIds = topFrequencyRecencyItems + .filter(item => item.key.startsWith(APPLICATION)) + .map(getTypeAndId) + .slice(0, 3) + + if (!shouldRefetch) { + // Fetch the 1000 last registered devices for the top 3 applications. + // This is a best effort to get the device names for the top entities. + await Promise.all( + topFrequencyRecencyApplicationIds.map(({ entityId }) => + dispatch( + attachPromise(getDevicesList(entityId, { page: 1, limit: 1000, order }, ['name'])), + ), + ), + ) + } + + // Always put the bookmarks first. + topEntities.push({ + category: 'bookmarks', + source: 'top-entities', + items: bookmarks.slice(0, MAX_ENTITIES).map(bookmark => { + const id = getBookmarkEntityId(bookmark) + const type = getBookmarkType(bookmark) + return { + id: getBookmarkEntityId(bookmark), + name: getEntityName(id, type, state), + type: getBookmarkType(bookmark), + path: getEntityPath(id, type, state), + } + }), + }) + + // Then add the top frequency recency items, but only if we have not already. + const slicedTopFrequencyRecencyItems = topFrequencyRecencyItems + .slice(0, MAX_ENTITIES - bookmarks.length) + .reduce((acc, item) => { + const { entityType, entityId } = getTypeAndId(item) + const path = getEntityPath(entityId, entityType, state) + // Skip if the entity is already in the list. + if (topEntities[0].items.find(e => e.path === path)) { + return acc + } + acc.push({ + id: entityId, + name: getEntityName(entityId, entityType, state), + type: entityType, + path, + }) + return acc + }, []) + + topEntities.push({ + category: 'recency', + source: 'top-entities', + items: slicedTopFrequencyRecencyItems, + }) - return topFrequencyRecencyItems + return topEntities }, }) diff --git a/pkg/webui/console/store/reducers/index.js b/pkg/webui/console/store/reducers/index.js index 13f88730ed..8b4849d67c 100644 --- a/pkg/webui/console/store/reducers/index.js +++ b/pkg/webui/console/store/reducers/index.js @@ -66,7 +66,6 @@ import ns from './network-server' import notifications from './notifications' import userPreferences from './user-preferences' import topEntities from './top-entities' - import search from './search' export default combineReducers({ @@ -130,5 +129,4 @@ export default combineReducers({ userPreferences, search, topEntities, - -}) \ No newline at end of file +}) diff --git a/pkg/webui/console/store/reducers/search.js b/pkg/webui/console/store/reducers/search.js index 374093f675..d2bd33da3b 100644 --- a/pkg/webui/console/store/reducers/search.js +++ b/pkg/webui/console/store/reducers/search.js @@ -14,15 +14,20 @@ import { handleActions } from 'redux-actions' -import { GET_GLOBAL_SEARCH_RESULTS_SUCCESS } from '@console/store/actions/search' +import { GET_GLOBAL_SEARCH_RESULTS_SUCCESS, SET_SEARCH_OPEN } from '@console/store/actions/search' const defaultState = { + searchOpen: false, results: [], query: '', } export default handleActions( { + [SET_SEARCH_OPEN]: (state, { payload: { searchOpen } }) => ({ + ...state, + searchOpen, + }), [GET_GLOBAL_SEARCH_RESULTS_SUCCESS]: (state, { payload: { query, results } }) => ({ ...state, results, diff --git a/pkg/webui/console/store/reducers/top-entities.js b/pkg/webui/console/store/reducers/top-entities.js index 60e5a524f0..629801cd0c 100644 --- a/pkg/webui/console/store/reducers/top-entities.js +++ b/pkg/webui/console/store/reducers/top-entities.js @@ -18,6 +18,7 @@ import { GET_TOP_ENTITIES_SUCCESS } from '@console/store/actions/top-entities' const defaultState = { data: [], + lastFetched: undefined, } export default handleActions( @@ -25,6 +26,7 @@ export default handleActions( [GET_TOP_ENTITIES_SUCCESS]: (state, { payload }) => ({ ...state, data: payload, + lastFetched: Date.now(), }), }, defaultState, diff --git a/pkg/webui/console/store/selectors/search.js b/pkg/webui/console/store/selectors/search.js index f868747988..5ee7583455 100644 --- a/pkg/webui/console/store/selectors/search.js +++ b/pkg/webui/console/store/selectors/search.js @@ -16,3 +16,4 @@ const selectSearchStore = state => state.search export const selectSearchResults = state => selectSearchStore(state).results export const selectSearchQuery = state => selectSearchStore(state).query +export const selectIsSearchOpen = state => selectSearchStore(state).searchOpen diff --git a/pkg/webui/console/store/selectors/top-entities.js b/pkg/webui/console/store/selectors/top-entities.js index 021ad8a136..24b2c64f83 100644 --- a/pkg/webui/console/store/selectors/top-entities.js +++ b/pkg/webui/console/store/selectors/top-entities.js @@ -15,3 +15,8 @@ const selectTopEntitiesStore = state => state.topEntities export const selectTopEntitiesData = state => selectTopEntitiesStore(state).data +export const selectConcatenatedTopEntitiesData = state => + selectTopEntitiesData(state).reduce((acc, entity) => acc.concat(entity.items), []) +export const selectConcatenatedTopEntitiesByType = (state, type) => + selectConcatenatedTopEntitiesData(state).filter(entity => entity.type === type) +export const selectTopEntitiesLastFetched = state => selectTopEntitiesStore(state).lastFetched From 2446f2012068ada74f4fc64e29426b665f6eb57a Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 14 May 2024 15:57:43 +0200 Subject: [PATCH 10/16] console,account: Add hover hook to link component --- pkg/webui/components/link/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/webui/components/link/index.js b/pkg/webui/components/link/index.js index bcfe5b40cf..4f0cbc79d5 100644 --- a/pkg/webui/components/link/index.js +++ b/pkg/webui/components/link/index.js @@ -57,6 +57,7 @@ const Link = props => { target, showVisited, onClick, + onMouseEnter, secondary, primary, tabIndex, @@ -88,6 +89,7 @@ const Link = props => { to={to} target={target} onClick={onClick} + onMouseEnter={onMouseEnter} tabIndex={tabIndex} role={role} > @@ -102,6 +104,7 @@ Link.propTypes = { disabled: PropTypes.bool, id: PropTypes.string, onClick: PropTypes.func, + onMouseEnter: PropTypes.func, primary: PropTypes.bool, replace: PropTypes.bool, role: PropTypes.string, @@ -128,6 +131,7 @@ Link.defaultProps = { disabled: false, id: undefined, onClick: () => null, + onMouseEnter: undefined, primary: false, showVisited: false, replace: false, From 34d56926b3a8dcdab9f4755af97a6ac348e11903 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 14 May 2024 16:08:04 +0200 Subject: [PATCH 11/16] console: Add click hook to open search --- pkg/webui/console/containers/sidebar/index.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/webui/console/containers/sidebar/index.js b/pkg/webui/console/containers/sidebar/index.js index 46d3b787f5..3f43a0ca73 100644 --- a/pkg/webui/console/containers/sidebar/index.js +++ b/pkg/webui/console/containers/sidebar/index.js @@ -15,6 +15,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { useLocation } from 'react-router-dom' import classnames from 'classnames' +import { useDispatch } from 'react-redux' import LAYOUT from '@ttn-lw/constants/layout' @@ -23,6 +24,8 @@ import SideFooter from '@ttn-lw/components/sidebar/side-footer' import PropTypes from '@ttn-lw/lib/prop-types' +import { setSearchOpen } from '@console/store/actions/search' + import SearchPanelManager from '../search-panel' import SidebarNavigation from './navigation' @@ -35,6 +38,7 @@ import style from './sidebar.styl' const Sidebar = ({ isDrawerOpen, onDrawerCloseClick }) => { const { pathname } = useLocation() const [isMinimized, setIsMinimized] = useState(false) + const dispatch = useDispatch() // Reset minimized state when screen size changes to mobile. useEffect(() => { @@ -58,6 +62,10 @@ const Sidebar = ({ isDrawerOpen, onDrawerCloseClick }) => { setIsMinimized(prev => !prev) }, [setIsMinimized]) + const handleSearchClick = useCallback(() => { + dispatch(setSearchOpen(true)) + }, [dispatch]) + const sidebarClassnames = classnames( style.sidebar, 'd-flex direction-column j-between c-bg-brand-extralight gap-cs-l', @@ -76,7 +84,7 @@ const Sidebar = ({ isDrawerOpen, onDrawerCloseClick }) => {
- null} /> +
From 87b57f6cbf34eba1689d145cac47322dd3648813 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 14 May 2024 16:08:40 +0200 Subject: [PATCH 12/16] console,account: Pass value to side effect function --- pkg/webui/lib/hooks/use-debounce.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/webui/lib/hooks/use-debounce.js b/pkg/webui/lib/hooks/use-debounce.js index 2063954aa0..1a897ebe1d 100644 --- a/pkg/webui/lib/hooks/use-debounce.js +++ b/pkg/webui/lib/hooks/use-debounce.js @@ -25,7 +25,7 @@ const useDebounce = (value, delay = 350, sideEffects) => { const timer = setTimeout(() => { setDebouncedValue(value) if (sideEffects) { - sideEffects() + sideEffects(value) } }, delay) From 343acc0f8b99e8ec07525612135eebb5e16db543 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 14 May 2024 16:10:12 +0200 Subject: [PATCH 13/16] console,account: Extend entity id selectors --- pkg/webui/lib/selectors/id.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/webui/lib/selectors/id.js b/pkg/webui/lib/selectors/id.js index f4ca289014..34b259693b 100644 --- a/pkg/webui/lib/selectors/id.js +++ b/pkg/webui/lib/selectors/id.js @@ -17,7 +17,8 @@ import getByPath from '../get-by-path' export const getApplicationId = (application = {}) => getByPath(application, 'application_id') || getByPath(application, 'application_ids.application_id') || - getByPath(application, 'ids.application_id') + getByPath(application, 'ids.application_id') || + getByPath(application, 'ids.application_ids.application_id') export const getDeviceId = (device = {}) => getByPath(device, 'device_id') || @@ -34,6 +35,15 @@ export const extractDeviceIdFromCombinedId = combinedId => { } return combinedId } +export const extractApplicationIdFromCombinedId = combinedId => { + if (typeof combinedId === 'string') { + const parts = combinedId.split('/') + if (parts.length === 2) { + return parts[0] + } + } + return combinedId +} export const getCombinedDeviceId = (device = {}) => { const appId = getByPath(device, 'ids.application_ids.application_id') || @@ -60,11 +70,11 @@ export const getOrganizationId = (organization = {}) => const idSelectors = [ getApplicationId, - getCollaboratorId, - getApiKeyId, getGatewayId, getDeviceId, getOrganizationId, + getCollaboratorId, + getApiKeyId, ] export const getEntityId = entity => { From 9ad2b762cb8d067b72f6738b6c2064f46f7874e7 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 14 May 2024 16:11:20 +0200 Subject: [PATCH 14/16] console,account: Add delay to overlay --- pkg/webui/components/overlay/overlay.styl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/webui/components/overlay/overlay.styl b/pkg/webui/components/overlay/overlay.styl index 108cd34907..dd09c7ddd3 100644 --- a/pkg/webui/components/overlay/overlay.styl +++ b/pkg/webui/components/overlay/overlay.styl @@ -22,7 +22,7 @@ top: 0 left: 0 background: var(--c-bg-neutral-min) - transition: opacity $ad.m ease-in-out, visibility $ad.m ease-in-out + transition: opacity $ad.m ease-in-out $ad.l, visibility $ad.m ease-in-out &-visible visibility: visible From 47c8a5166727dd187b3fb71f9f2b8f40a7e82059 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Tue, 14 May 2024 16:12:09 +0200 Subject: [PATCH 15/16] console,account: Update shared messages --- pkg/webui/lib/shared-messages.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/webui/lib/shared-messages.js b/pkg/webui/lib/shared-messages.js index efe278f441..e44f64a295 100644 --- a/pkg/webui/lib/shared-messages.js +++ b/pkg/webui/lib/shared-messages.js @@ -97,6 +97,7 @@ export default defineMessages({ backToOverview: 'Back to overview', beaconFrequency: 'Beacon frequency', bearerMyAuthToken: 'Bearer my-auth-token', + bookmarks: 'Bookmarks', brand: 'Brand', cancel: 'Cancel', changeLocation: 'Change location settings', @@ -472,6 +473,7 @@ export default defineMessages({ 'Configure gateway delay (minimum: {minimumValue}ms, default: {defaultValue}ms)', scheduleDownlinkLateDescription: 'Enable server-side buffer of downlink messages', search: 'Search', + searching: 'Searching…', secondInterval: '{count, plural, one {every second} other {every {count} seconds}}', seconds: 'seconds', secondsAbbreviated: 'sec', From fb5228772d9083d9b4a33a9fa87d953b0c080da9 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Wed, 15 May 2024 17:33:57 +0200 Subject: [PATCH 16/16] console,account: Update locales --- pkg/webui/locales/en.json | 10 ++++++++++ pkg/webui/locales/ja.json | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/pkg/webui/locales/en.json b/pkg/webui/locales/en.json index 7f85b65598..f8b436f5e5 100644 --- a/pkg/webui/locales/en.json +++ b/pkg/webui/locales/en.json @@ -178,6 +178,14 @@ "components.safe-inspector.index.toggleVisibility": "Toggle visibility", "components.safe-inspector.index.arrayFormatting": "Toggle array formatting", "components.safe-inspector.index.byteOrder": "Switch byte order", + "components.search-panel.index.noResultsFound": "No results found", + "components.search-panel.index.noResultsSuggestion": "Try searching for IDs names, attributes, EUIs or descriptions of:", + "components.search-panel.index.devices": "End devices of your{lineBreak}bookmarked applications", + "components.search-panel.index.searchingEntities": "Searching applications, gateways, organizations, bookmarks", + "components.search-panel.index.instructions": "Use {arrowKeys} to choose, {enter} to select", + "components.search-panel.index.fetchingTopEntities": "Fetching top entities…", + "components.search-panel.index.noTopEntities": "Seems like you haven’t interacted with any entities yet", + "components.search-panel.index.noTopEntitiesSuggestion": "Once you created or interacted with entities, they will show up here and you can use this panel to quickly search and navigate to them.", "components.status.index.good": "good", "components.status.index.bad": "bad", "components.status.index.mediocre": "mediocre", @@ -1126,6 +1134,7 @@ "lib.shared-messages.backToOverview": "Back to overview", "lib.shared-messages.beaconFrequency": "Beacon frequency", "lib.shared-messages.bearerMyAuthToken": "Bearer my-auth-token", + "lib.shared-messages.bookmarks": "Bookmarks", "lib.shared-messages.brand": "Brand", "lib.shared-messages.cancel": "Cancel", "lib.shared-messages.changeLocation": "Change location settings", @@ -1474,6 +1483,7 @@ "lib.shared-messages.scheduleAnyTimeDescription": "Configure gateway delay (minimum: {minimumValue}ms, default: {defaultValue}ms)", "lib.shared-messages.scheduleDownlinkLateDescription": "Enable server-side buffer of downlink messages", "lib.shared-messages.search": "Search", + "lib.shared-messages.searching": "Searching…", "lib.shared-messages.secondInterval": "{count, plural, one {every second} other {every {count} seconds}}", "lib.shared-messages.seconds": "seconds", "lib.shared-messages.secondsAbbreviated": "sec", diff --git a/pkg/webui/locales/ja.json b/pkg/webui/locales/ja.json index 318edb32fd..c2ed0e5fec 100644 --- a/pkg/webui/locales/ja.json +++ b/pkg/webui/locales/ja.json @@ -178,6 +178,14 @@ "components.safe-inspector.index.toggleVisibility": "切り替え表示", "components.safe-inspector.index.arrayFormatting": "配列フォーマットの切り替え", "components.safe-inspector.index.byteOrder": "バイト順の切り替え", + "components.search-panel.index.noResultsFound": "", + "components.search-panel.index.noResultsSuggestion": "", + "components.search-panel.index.devices": "", + "components.search-panel.index.searchingEntities": "", + "components.search-panel.index.instructions": "", + "components.search-panel.index.fetchingTopEntities": "", + "components.search-panel.index.noTopEntities": "", + "components.search-panel.index.noTopEntitiesSuggestion": "", "components.status.index.good": "good", "components.status.index.bad": "bad", "components.status.index.mediocre": "普通", @@ -1126,6 +1134,7 @@ "lib.shared-messages.backToOverview": "概要に戻る", "lib.shared-messages.beaconFrequency": "ビーコン周波数", "lib.shared-messages.bearerMyAuthToken": "私の認証トークン", + "lib.shared-messages.bookmarks": "", "lib.shared-messages.brand": "ブランド", "lib.shared-messages.cancel": "キャンセル", "lib.shared-messages.changeLocation": "場所の設定を変更", @@ -1474,6 +1483,7 @@ "lib.shared-messages.scheduleAnyTimeDescription": "ゲートウェイ遅延の設定 (最小値: {minimumValue}ms, デフォルト値: {defaultValue}ms)", "lib.shared-messages.scheduleDownlinkLateDescription": "ダウンリンクメッセージのサーバ側バッファを有効にします", "lib.shared-messages.search": "検索", + "lib.shared-messages.searching": "", "lib.shared-messages.secondInterval": "", "lib.shared-messages.seconds": "秒", "lib.shared-messages.secondsAbbreviated": "sec",