diff --git a/pkg/webui/components/breadcrumbs/context.js b/pkg/webui/components/breadcrumbs/context.js index ba6e6334a2..9c82336786 100644 --- a/pkg/webui/components/breadcrumbs/context.js +++ b/pkg/webui/components/breadcrumbs/context.js @@ -60,7 +60,7 @@ const useBreadcrumbs = (id, element) => { context.remove(id) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [id]) } export { Consumer as BreadcrumbsConsumer, BreadcrumbsProvider, BreadcrumbsContext, useBreadcrumbs } diff --git a/pkg/webui/components/dropdown/index.js b/pkg/webui/components/dropdown/index.js index 22abd46d69..ed94242db3 100644 --- a/pkg/webui/components/dropdown/index.js +++ b/pkg/webui/components/dropdown/index.js @@ -169,7 +169,7 @@ DropdownItem.propTypes = { active: PropTypes.bool, exact: PropTypes.bool, external: PropTypes.bool, - icon: PropTypes.string, + icon: PropTypes.shape({}), messageClassName: PropTypes.string, path: PropTypes.string, showActive: PropTypes.bool, diff --git a/pkg/webui/components/header/header.styl b/pkg/webui/components/header/header.styl index be5f834d93..35ca7aa716 100644 --- a/pkg/webui/components/header/header.styl +++ b/pkg/webui/components/header/header.styl @@ -32,6 +32,7 @@ .bookmarks-dropdown max-width: 19.3rem width: 19.3rem // Avoids flash of small container when dropdown is opened + padding: 0 .logo height: $cs.l diff --git a/pkg/webui/components/panel/toggle/index.js b/pkg/webui/components/panel/toggle/index.js index e94045f2a3..e17deebf3a 100644 --- a/pkg/webui/components/panel/toggle/index.js +++ b/pkg/webui/components/panel/toggle/index.js @@ -53,7 +53,7 @@ Toggle.propTypes = { onToggleChange: PropTypes.func.isRequired, options: PropTypes.arrayOf( PropTypes.shape({ - label: PropTypes.string.isRequired, + label: PropTypes.message.isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, }), ).isRequired, diff --git a/pkg/webui/components/panel/toggle/toggle.styl b/pkg/webui/components/panel/toggle/toggle.styl index ca86e3c581..b1badc56b5 100644 --- a/pkg/webui/components/panel/toggle/toggle.styl +++ b/pkg/webui/components/panel/toggle/toggle.styl @@ -39,7 +39,7 @@ &:hover:not(.toggle-button-active) background: var(--c-bg-neutral-min-hover) -@container panel (max-width: 395px) +@container panel (max-width: 485px) .toggle width: 100% diff --git a/pkg/webui/components/sidebar/section-label/index.js b/pkg/webui/components/sidebar/section-label/index.js index 6fc2bdf659..249a7acf9e 100644 --- a/pkg/webui/components/sidebar/section-label/index.js +++ b/pkg/webui/components/sidebar/section-label/index.js @@ -14,12 +14,25 @@ import React from 'react' import classnames from 'classnames' +import { useSelector } from 'react-redux' +import { IconApplication, IconDevice, IconGateway, IconOrganization } from '@ttn-lw/components/icon' import Button from '@ttn-lw/components/button' +import Dropdown from '@ttn-lw/components/dropdown' 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 { + checkFromState, + mayViewApplications, + mayViewGateways, + mayViewOrganizationsOfUser, +} from '@console/lib/feature-checks' + +import { selectUser } from '@console/store/selectors/logout' const SectionLabel = ({ label, @@ -28,23 +41,71 @@ const SectionLabel = ({ onClick, buttonDisabled, 'data-test-id': dataTestId, -}) => ( -
- -
-) +}) => { + const user = useSelector(selectUser) + const mayViewApps = useSelector(state => + user ? checkFromState(mayViewApplications, state) : false, + ) + const mayViewGtws = useSelector(state => (user ? checkFromState(mayViewGateways, state) : false)) + const mayViewOrgs = useSelector(state => + user ? checkFromState(mayViewOrganizationsOfUser, state) : false, + ) + + const plusDropdownItems = ( + <> + {mayViewApps && ( + + )} + {mayViewGtws && ( + + )} + {mayViewOrgs && ( + + )} + + + + ) + + return ( +
+ +
+ ) +} SectionLabel.propTypes = { buttonDisabled: PropTypes.bool, diff --git a/pkg/webui/components/spinner/index.js b/pkg/webui/components/spinner/index.js index 2ba62cf797..6e8ef56c06 100644 --- a/pkg/webui/components/spinner/index.js +++ b/pkg/webui/components/spinner/index.js @@ -31,6 +31,7 @@ const Spinner = ({ micro = false, small, inline = false, + right = false, }) => { const [visible, setVisible] = useState(false) const visibilityTimeout = setTimeout(() => setVisible(true), after) @@ -52,6 +53,7 @@ const Spinner = ({ faded, visible, inline, + right, }), ) @@ -82,6 +84,7 @@ Spinner.propTypes = { faded: PropTypes.bool, inline: PropTypes.bool, micro: PropTypes.bool, + right: PropTypes.bool, small: PropTypes.bool, } @@ -94,6 +97,7 @@ Spinner.defaultProps = { inline: false, micro: false, small: false, + right: false, } export default Spinner diff --git a/pkg/webui/components/spinner/spinner.styl b/pkg/webui/components/spinner/spinner.styl index 0f7fc1b6a9..0db3f6f5fe 100644 --- a/pkg/webui/components/spinner/spinner.styl +++ b/pkg/webui/components/spinner/spinner.styl @@ -33,6 +33,10 @@ $xs = $cs.m justify-content: flex-start align-items: center + &.right + &:not(.inline) + text-align: right + .spinner width: $l height: $l diff --git a/pkg/webui/components/table/row/index.js b/pkg/webui/components/table/row/index.js index 11e5b235b6..e71de95d44 100644 --- a/pkg/webui/components/table/row/index.js +++ b/pkg/webui/components/table/row/index.js @@ -96,7 +96,7 @@ Row.propTypes = { /** A flag indicating whether the row is wrapping the head of a table. */ head: PropTypes.bool, /** The identifier of the row. */ - id: PropTypes.number, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** The href to be passed as `to` prop to the `` component that wraps the row. */ linkTo: PropTypes.string, /** diff --git a/pkg/webui/console/containers/header/bookmarks-dropdown.js b/pkg/webui/console/containers/header/bookmarks-dropdown.js index 96b034013b..e971cbfd38 100644 --- a/pkg/webui/console/containers/header/bookmarks-dropdown.js +++ b/pkg/webui/console/containers/header/bookmarks-dropdown.js @@ -34,9 +34,16 @@ const m = defineMessages({ }) const Bookmark = ({ bookmark }) => { - const { title, path, icon } = useBookmark(bookmark) + const { title, ids, path, icon } = useBookmark(bookmark) - return + return ( + + ) } Bookmark.propTypes = { @@ -61,9 +68,11 @@ const BookmarksDropdown = () => { ) : ( <> - {dropdownItems.slice(0, 15).map(bookmark => ( - - ))} +
+ {dropdownItems.slice(0, 15).map((bookmark, index) => ( + + ))} +
{dropdownItems.length > 15 && (
diff --git a/pkg/webui/console/containers/header/header.styl b/pkg/webui/console/containers/header/header.styl index c4e9cd12a7..b57fd82fbd 100644 --- a/pkg/webui/console/containers/header/header.styl +++ b/pkg/webui/console/containers/header/header.styl @@ -63,3 +63,7 @@ .bookmark overflow: hidden text-overflow: ellipsis + + &-container + padding: $cs.xs + border-bottom: 1px solid var(--c-border-neutral-light) diff --git a/pkg/webui/console/containers/header/index.js b/pkg/webui/console/containers/header/index.js index c486c5f29d..4e3fd513a3 100644 --- a/pkg/webui/console/containers/header/index.js +++ b/pkg/webui/console/containers/header/index.js @@ -14,7 +14,6 @@ import React, { useCallback } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { defineMessages } from 'react-intl' import { IconLogout, @@ -52,12 +51,6 @@ import Logo from '../logo' import NotificationsDropdown from './notifications-dropdown' import BookmarksDropdown from './bookmarks-dropdown' -const m = defineMessages({ - addApplication: 'Add new application', - addGateway: 'Add new gateway', - addOrganization: 'Add new organization', -}) - const accountUrl = selectAccountUrl() const Header = ({ onMenuClick }) => { @@ -78,14 +71,18 @@ const Header = ({ onMenuClick }) => { const plusDropdownItems = ( <> {mayViewApps && ( - + )} {mayViewGtws && ( - + )} {mayViewOrgs && ( diff --git a/pkg/webui/console/containers/shortcut-panel/index.js b/pkg/webui/console/containers/shortcut-panel/index.js index 133e06de3c..44e2fa69ea 100644 --- a/pkg/webui/console/containers/shortcut-panel/index.js +++ b/pkg/webui/console/containers/shortcut-panel/index.js @@ -30,11 +30,11 @@ import ShortcutItem from './shortcut-item' const m = defineMessages({ shortcuts: 'Quick actions', - addApplication: 'New Application', - addGateway: 'New Gateway', - addOrganization: 'New Organization', + addApplication: 'New application', + addGateway: 'New gateway', + addNewOrganization: 'New organization', addPersonalApiKey: 'New personal API key', - registerDevice: 'Register a device', + registerDevice: 'Register an end device', }) const ShortcutPanel = () => ( @@ -54,7 +54,7 @@ const ShortcutPanel = () => ( /> diff --git a/pkg/webui/console/containers/sidebar/navigation/app-list-side-navigation.js b/pkg/webui/console/containers/sidebar/navigation/app-list-side-navigation.js index 6f3ad3d85c..59d9ce5549 100644 --- a/pkg/webui/console/containers/sidebar/navigation/app-list-side-navigation.js +++ b/pkg/webui/console/containers/sidebar/navigation/app-list-side-navigation.js @@ -13,17 +13,19 @@ // limitations under the License. import React, { useContext } from 'react' +import { useSelector } from 'react-redux' -import { IconPlus } from '@ttn-lw/components/icon' -import SectionLabel from '@ttn-lw/components/sidebar/section-label' -import SideNavigation from '@ttn-lw/components/sidebar/side-menu' - -import sharedMessages from '@ttn-lw/lib/shared-messages' +import { selectUserId } from '@console/store/selectors/logout' +import { selectPerEntityBookmarks } from '@console/store/selectors/user-preferences' import SidebarContext from '../context' +import TopEntitiesSection from './top-entities-section' + const AppListSideNavigation = () => { - const { topEntities, isMinimized } = useContext(SidebarContext) + const topEntities = useSelector(state => selectPerEntityBookmarks(state, 'application')) + const { isMinimized } = useContext(SidebarContext) + const userId = useSelector(selectUserId) if (isMinimized || topEntities.length === 0) { // Rendering an empty div to prevent the shadow of the search bar @@ -32,16 +34,7 @@ const AppListSideNavigation = () => { return
} - return ( -
- null} /> - - {topEntities.map(({ path, entity, title }) => ( - - ))} - -
- ) + return } export default AppListSideNavigation diff --git a/pkg/webui/console/containers/sidebar/navigation/general-side-navigation.js b/pkg/webui/console/containers/sidebar/navigation/general-side-navigation.js index 60710e2563..c85940dff3 100644 --- a/pkg/webui/console/containers/sidebar/navigation/general-side-navigation.js +++ b/pkg/webui/console/containers/sidebar/navigation/general-side-navigation.js @@ -22,11 +22,9 @@ import { IconLayoutDashboard, IconUserShield, IconKey, - IconPlus, IconInbox, } from '@ttn-lw/components/icon' import SideNavigation from '@ttn-lw/components/sidebar/side-menu' -import SectionLabel from '@ttn-lw/components/sidebar/section-label' import sharedMessages from '@ttn-lw/lib/shared-messages' @@ -38,12 +36,15 @@ import { import getCookie from '@console/lib/table-utils' import { selectUser, selectUserIsAdmin } from '@console/store/selectors/logout' +import { selectBookmarksList } from '@console/store/selectors/user-preferences' import SidebarContext from '../context' -const GeneralSideNavigation = () => { - const { topEntities, isMinimized } = useContext(SidebarContext) +import TopEntitiesSection from './top-entities-section' +const GeneralSideNavigation = () => { + const { isMinimized } = useContext(SidebarContext) + const topEntities = useSelector(state => selectBookmarksList(state)) const isUserAdmin = useSelector(selectUserIsAdmin) const user = useSelector(selectUser) const mayViewOrgs = useSelector(state => @@ -94,13 +95,8 @@ const GeneralSideNavigation = () => { /> )} - {!isMinimized && ( - - null} /> - {topEntities.map(({ path, title, entity }) => ( - - ))} - + {!isMinimized && topEntities.length > 0 && ( + )} ) diff --git a/pkg/webui/console/containers/sidebar/navigation/gtw-list-side-navigation.js b/pkg/webui/console/containers/sidebar/navigation/gtw-list-side-navigation.js index a2bcce07d9..ea69bf246f 100644 --- a/pkg/webui/console/containers/sidebar/navigation/gtw-list-side-navigation.js +++ b/pkg/webui/console/containers/sidebar/navigation/gtw-list-side-navigation.js @@ -13,31 +13,40 @@ // limitations under the License. import React, { useContext } from 'react' +import { useSelector } from 'react-redux' -import { IconPlus } from '@ttn-lw/components/icon' -import SectionLabel from '@ttn-lw/components/sidebar/section-label' import SideNavigation from '@ttn-lw/components/sidebar/side-menu' -import sharedMessages from '@ttn-lw/lib/shared-messages' +import useBookmark from '@ttn-lw/lib/hooks/use-bookmark' +import PropTypes from '@ttn-lw/lib/prop-types' + +import { selectUserId } from '@console/store/selectors/logout' +import { selectPerEntityBookmarks } from '@console/store/selectors/user-preferences' import SidebarContext from '../context' +import TopEntitiesSection from './top-entities-section' + +const Bookmark = ({ bookmark }) => { + const { title, ids, path, icon } = useBookmark(bookmark) + + return +} + +Bookmark.propTypes = { + bookmark: PropTypes.shape({}).isRequired, +} + const GtwListSideNavigation = () => { - const { topEntities, isMinimized } = useContext(SidebarContext) + const topEntities = useSelector(state => selectPerEntityBookmarks(state, 'gateway')) + const { isMinimized } = useContext(SidebarContext) + const userId = useSelector(selectUserId) + if (isMinimized || topEntities.length === 0) { return
} - return ( -
- null} /> - - {topEntities.map(({ path, entity, title }) => ( - - ))} - -
- ) + return } export default GtwListSideNavigation diff --git a/pkg/webui/console/containers/sidebar/navigation/index.js b/pkg/webui/console/containers/sidebar/navigation/index.js index f1594e3621..aa48f2b4a9 100644 --- a/pkg/webui/console/containers/sidebar/navigation/index.js +++ b/pkg/webui/console/containers/sidebar/navigation/index.js @@ -27,9 +27,13 @@ const SidebarNavigation = () => { const isApplicationsPath = pathname.startsWith('/applications') const isGatewaysPath = pathname.startsWith('/gateways') const isSingleAppPath = - isApplicationsPath && /\/applications\/[a-z0-9]+([-]?[a-z0-9]+)*\/?/i.test(pathname) + isApplicationsPath && + /\/applications\/[a-z0-9]+([-]?[a-z0-9]+)*\/?/i.test(pathname) && + !pathname.endsWith('/add') const isSingleGatewayPath = - isGatewaysPath && /\/gateways\/[a-z0-9]+([-]?[a-z0-9]+)*\/?/i.test(pathname) + isGatewaysPath && + /\/gateways\/[a-z0-9]+([-]?[a-z0-9]+)*\/?/i.test(pathname) && + !pathname.endsWith('/add') return ( <> diff --git a/pkg/webui/console/containers/sidebar/navigation/top-entities-section.js b/pkg/webui/console/containers/sidebar/navigation/top-entities-section.js new file mode 100644 index 0000000000..01d5b3ff89 --- /dev/null +++ b/pkg/webui/console/containers/sidebar/navigation/top-entities-section.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. + +import React, { useCallback, useState } from 'react' + +import { IconPlus } from '@ttn-lw/components/icon' +import Button from '@ttn-lw/components/button' +import SideNavigation from '@ttn-lw/components/sidebar/side-menu' +import SectionLabel from '@ttn-lw/components/sidebar/section-label' + +import sharedMessages from '@ttn-lw/lib/shared-messages' +import PropTypes from '@ttn-lw/lib/prop-types' +import useBookmark from '@ttn-lw/lib/hooks/use-bookmark' + +const Bookmark = ({ bookmark }) => { + const { title, ids, path, icon } = useBookmark(bookmark) + + return +} + +Bookmark.propTypes = { + bookmark: PropTypes.shape({}).isRequired, +} + +const TopEntitiesSection = ({ topEntities, entity }) => { + const [showMore, setShowMore] = useState(false) + + const handleShowMore = useCallback(async () => { + setShowMore(showMore => !showMore) + }, []) + + const label = entity + ? entity === 'gateway' + ? sharedMessages.topGateways + : sharedMessages.topApplications + : sharedMessages.topEntities + + return ( + + null} /> + {topEntities.slice(0, 6).map((bookmark, index) => ( + + ))} + {showMore && + topEntities.length > 6 && + topEntities + .slice(6, topEntities.length) + .map((bookmark, index) => )} + {topEntities.length > 6 && ( +