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,
-}) => (
-
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 && (
+
+ )}
+
+ )
+}
+
+TopEntitiesSection.propTypes = {
+ entity: PropTypes.string,
+ topEntities: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
+}
+
+TopEntitiesSection.defaultProps = {
+ entity: undefined,
+}
+
+export default TopEntitiesSection
diff --git a/pkg/webui/console/containers/top-entities-dashboard-panel/all-top-entities/index.js b/pkg/webui/console/containers/top-entities-dashboard-panel/all-top-entities/index.js
new file mode 100644
index 0000000000..a49f1056e3
--- /dev/null
+++ b/pkg/webui/console/containers/top-entities-dashboard-panel/all-top-entities/index.js
@@ -0,0 +1,66 @@
+// 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 { defineMessages } from 'react-intl'
+import { useSelector } from 'react-redux'
+
+import Icon from '@ttn-lw/components/icon'
+
+import Message from '@ttn-lw/lib/components/message'
+
+import sharedMessages from '@ttn-lw/lib/shared-messages'
+
+import {
+ selectBookmarksList,
+ selectBookmarksTotalCount,
+} from '@console/store/selectors/user-preferences'
+
+import EntitiesList from '../list'
+
+const m = defineMessages({
+ noTopEntities: 'No top entities yet',
+ noTopEntitiesDescription: 'Your most visited, and bookmarked entities will be listed here.',
+})
+
+const AllTopEntitiesList = () => {
+ const allBookmarks = useSelector(state => selectBookmarksList(state))
+
+ const headers = [
+ {
+ name: 'type',
+ displayName: sharedMessages.type,
+ width: '35px',
+ render: icon =>
,
+ },
+ {
+ name: 'name',
+ displayName: sharedMessages.name,
+ align: 'left',
+ render: (name, id) =>
,
+ },
+ ]
+
+ return (
+
+ )
+}
+
+export default AllTopEntitiesList
diff --git a/pkg/webui/console/containers/top-entities-dashboard-panel/index.js b/pkg/webui/console/containers/top-entities-dashboard-panel/index.js
new file mode 100644
index 0000000000..131e8110ca
--- /dev/null
+++ b/pkg/webui/console/containers/top-entities-dashboard-panel/index.js
@@ -0,0 +1,69 @@
+// 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 { defineMessages } from 'react-intl'
+import classNames from 'classnames'
+
+import { IconStar } from '@ttn-lw/components/icon'
+import Panel from '@ttn-lw/components/panel'
+
+import sharedMessages from '@ttn-lw/lib/shared-messages'
+
+import AllTopEntitiesList from './all-top-entities'
+import TopApplicationsList from './top-applications'
+import TopGatewaysList from './top-gateways'
+import TopDevicesList from './top-devices'
+
+import styles from './top-entities-panel.styl'
+
+const m = defineMessages({
+ title: 'Your top entities',
+})
+
+const TopEntitiesDashboardPanel = () => {
+ const [active, setActive] = useState('all')
+
+ const handleChange = useCallback(
+ (_, value) => {
+ setActive(value)
+ },
+ [setActive],
+ )
+
+ const options = [
+ { label: sharedMessages.all, value: 'all' },
+ { label: sharedMessages.applications, value: 'applications' },
+ { label: sharedMessages.gateways, value: 'gateways' },
+ { label: sharedMessages.devices, value: 'end-devices' },
+ ]
+
+ return (
+
+ {active === 'all' && }
+ {active === 'applications' && }
+ {active === 'gateways' && }
+ {active === 'end-devices' && }
+
+ )
+}
+
+export default TopEntitiesDashboardPanel
diff --git a/pkg/webui/console/containers/top-entities-dashboard-panel/item.js b/pkg/webui/console/containers/top-entities-dashboard-panel/item.js
new file mode 100644
index 0000000000..d7d2553de1
--- /dev/null
+++ b/pkg/webui/console/containers/top-entities-dashboard-panel/item.js
@@ -0,0 +1,75 @@
+// 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 classNames from 'classnames'
+
+import { Table } from '@ttn-lw/components/table'
+
+import useBookmark from '@ttn-lw/lib/hooks/use-bookmark'
+import PropTypes from '@ttn-lw/lib/prop-types'
+
+import styles from './top-entities-panel.styl'
+
+const EntitiesItem = ({ bookmark, headers, last }) => {
+ const { title, ids, path, icon } = useBookmark(bookmark)
+
+ return (
+
+ {headers.map((header, index) => {
+ const value =
+ headers[index].name === 'name' ? title : headers[index].name === 'type' ? icon : ''
+ const entityID = ids.id
+ return (
+
+ {headers[index].render(value, entityID)}
+
+ )
+ })}
+
+ )
+}
+
+EntitiesItem.propTypes = {
+ bookmark: PropTypes.shape({}).isRequired,
+ headers: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ displayName: PropTypes.shape({}),
+ render: PropTypes.func,
+ getValue: PropTypes.func,
+ align: PropTypes.string,
+ }),
+ ).isRequired,
+ last: PropTypes.bool,
+}
+
+EntitiesItem.defaultProps = {
+ last: false,
+}
+
+export default EntitiesItem
diff --git a/pkg/webui/console/containers/top-entities-dashboard-panel/list.js b/pkg/webui/console/containers/top-entities-dashboard-panel/list.js
new file mode 100644
index 0000000000..2bce2f6328
--- /dev/null
+++ b/pkg/webui/console/containers/top-entities-dashboard-panel/list.js
@@ -0,0 +1,189 @@
+// 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 { FixedSizeList as List } from 'react-window'
+import InfiniteLoader from 'react-window-infinite-loader'
+import AutoSizer from 'react-virtualized-auto-sizer'
+import { useSelector } from 'react-redux'
+import { defineMessages } from 'react-intl'
+
+import Spinner from '@ttn-lw/components/spinner'
+import { Table } from '@ttn-lw/components/table'
+import { IconPlus } from '@ttn-lw/components/icon'
+import Button from '@ttn-lw/components/button'
+
+import Message from '@ttn-lw/lib/components/message'
+
+import PropTypes from '@ttn-lw/lib/prop-types'
+
+import EntitiesItem from './item'
+
+import styles from './top-entities-panel.styl'
+
+const m = defineMessages({
+ empty: 'No entities yet',
+})
+
+const EntitiesList = ({
+ itemsCountSelector,
+ allBookmarks,
+ headers,
+ emptyMessage,
+ emptyDescription,
+ emptyAction,
+ emptyPath,
+ EntitiesItemComponent: EntitiesItemProp,
+ entity,
+}) => {
+ const itemsTotalCount = useSelector(state => itemsCountSelector(state, entity))
+ const [items, setItems] = useState([])
+ const showScrollIndicator = itemsTotalCount > 5
+ const hasNextPage = items.length < itemsTotalCount
+ const EntitiesItemComponent = EntitiesItemProp ?? EntitiesItem
+ const itemCount = items.length === itemsTotalCount ? itemsTotalCount : items.length + 1
+ const [fetching, setFetching] = useState(false)
+
+ const loadNextPage = useCallback(
+ async startIndex => {
+ if (fetching) return
+ setFetching(true)
+ const nextBookmarks = allBookmarks.slice(startIndex, startIndex + 10)
+ setItems([...items, ...nextBookmarks])
+
+ setFetching(false)
+ },
+ [fetching, allBookmarks, items],
+ )
+
+ const isItemLoaded = useCallback(
+ index => (items.length > 0 ? !hasNextPage || index < items.length : false),
+ [hasNextPage, items],
+ )
+
+ const Item = ({ index, style }) =>
+ isItemLoaded(index) && items[index] ? (
+
+
+
+ ) : (
+
+
+
+ )
+
+ Item.propTypes = {
+ index: PropTypes.number.isRequired,
+ style: PropTypes.shape({}).isRequired,
+ }
+
+ const columns = (
+
+ {headers.map((header, key) => (
+
+ ))}
+
+ )
+
+ const minWidth = `${headers.length * 10 + 5}rem`
+
+ return items.length === 0 && itemsTotalCount === 0 ? (
+
+
+
+
+
+ {emptyAction && (
+
+
+
+ )}
+
+ ) : (
+
+ {columns}
+
+
+ {({ width }) => (
+
+ {({ onItemsRendered, ref }) => (
+ <>
+
+ {Item}
+
+ {showScrollIndicator && }
+ >
+ )}
+
+ )}
+
+
+
+ )
+}
+
+EntitiesList.propTypes = {
+ EntitiesItemComponent: PropTypes.func,
+ allBookmarks: PropTypes.arrayOf(PropTypes.object).isRequired,
+ emptyAction: PropTypes.message,
+ emptyDescription: PropTypes.message,
+ emptyMessage: PropTypes.message,
+ emptyPath: PropTypes.string,
+ entity: PropTypes.string,
+ headers: PropTypes.arrayOf(
+ PropTypes.shape({
+ align: PropTypes.string,
+ displayName: PropTypes.message,
+ name: PropTypes.string,
+ width: PropTypes.string,
+ className: PropTypes.string,
+ }),
+ ).isRequired,
+ itemsCountSelector: PropTypes.func.isRequired,
+}
+
+EntitiesList.defaultProps = {
+ emptyDescription: undefined,
+ emptyMessage: undefined,
+ emptyAction: undefined,
+ emptyPath: undefined,
+ entity: undefined,
+ EntitiesItemComponent: undefined,
+}
+
+export default EntitiesList
diff --git a/pkg/webui/console/containers/top-entities-dashboard-panel/top-applications/index.js b/pkg/webui/console/containers/top-entities-dashboard-panel/top-applications/index.js
new file mode 100644
index 0000000000..cbd125f7d0
--- /dev/null
+++ b/pkg/webui/console/containers/top-entities-dashboard-panel/top-applications/index.js
@@ -0,0 +1,84 @@
+// 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 { FormattedNumber, defineMessages } from 'react-intl'
+import { useSelector } from 'react-redux'
+
+import Spinner from '@ttn-lw/components/spinner'
+
+import Message from '@ttn-lw/lib/components/message'
+
+import sharedMessages from '@ttn-lw/lib/shared-messages'
+
+import {
+ selectPerEntityBookmarks,
+ selectPerEntityTotalCount,
+} from '@console/store/selectors/user-preferences'
+
+import EntitiesList from '../list'
+
+import TopApplicationsItem from './item'
+
+const m = defineMessages({
+ emptyMessage: 'No top application yet',
+ emptyDescription: 'Your most visited, and bookmarked applications will be listed here',
+})
+
+const TopApplicationsList = () => {
+ const allBookmarks = useSelector(state => selectPerEntityBookmarks(state, 'application'))
+
+ const headers = [
+ {
+ name: 'name',
+ displayName: sharedMessages.name,
+ render: (name, id) => (
+ <>
+
+ {name && (
+
+ )}
+ >
+ ),
+ },
+ {
+ name: 'deviceCount',
+ displayName: sharedMessages.devicesShort,
+ render: deviceCount =>
+ typeof deviceCount !== 'number' ? (
+
+ ) : (
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ )
+}
+
+export default TopApplicationsList
diff --git a/pkg/webui/console/containers/top-entities-dashboard-panel/top-applications/item.js b/pkg/webui/console/containers/top-entities-dashboard-panel/top-applications/item.js
new file mode 100644
index 0000000000..d45cebf8cd
--- /dev/null
+++ b/pkg/webui/console/containers/top-entities-dashboard-panel/top-applications/item.js
@@ -0,0 +1,98 @@
+// 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 { useSelector } from 'react-redux'
+import classNames from 'classnames'
+import { defineMessages } from 'react-intl'
+
+import { Table } from '@ttn-lw/components/table'
+
+import RequireRequest from '@ttn-lw/lib/components/require-request'
+import Message from '@ttn-lw/lib/components/message'
+
+import useBookmark from '@ttn-lw/lib/hooks/use-bookmark'
+import PropTypes from '@ttn-lw/lib/prop-types'
+
+import { getApplicationDeviceCount } from '@console/store/actions/applications'
+
+import { selectApplicationDeviceCount } from '@console/store/selectors/applications'
+
+import styles from '../top-entities-panel.styl'
+
+const m = defineMessages({
+ errorMessage: 'Not available',
+})
+
+const TopApplicationsItem = ({ bookmark, headers, last }) => {
+ const { title, ids, path } = useBookmark(bookmark)
+ const deviceCount = useSelector(state => selectApplicationDeviceCount(state, ids.id))
+
+ const loadDeviceCount = useCallback(
+ async dispatch => {
+ if (!deviceCount) {
+ dispatch(getApplicationDeviceCount(ids.id))
+ }
+ },
+ [deviceCount, ids.id],
+ )
+
+ const errorRenderFunction = () =>
+
+ return (
+
+ {headers.map((header, index) => {
+ const value = headers[index].name === 'name' ? title : deviceCount
+ const entityID = ids.id
+ return (
+
+
+ {headers[index].render(value, entityID)}
+
+
+ )
+ })}
+
+ )
+}
+
+TopApplicationsItem.propTypes = {
+ bookmark: PropTypes.shape({}).isRequired,
+ headers: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ displayName: PropTypes.shape({}).isRequired,
+ render: PropTypes.func,
+ getValue: PropTypes.func,
+ align: PropTypes.string,
+ }),
+ ).isRequired,
+ last: PropTypes.bool,
+}
+
+TopApplicationsItem.defaultProps = {
+ last: false,
+}
+
+export default TopApplicationsItem
diff --git a/pkg/webui/console/containers/top-entities-dashboard-panel/top-devices/index.js b/pkg/webui/console/containers/top-entities-dashboard-panel/top-devices/index.js
new file mode 100644
index 0000000000..2e52457f66
--- /dev/null
+++ b/pkg/webui/console/containers/top-entities-dashboard-panel/top-devices/index.js
@@ -0,0 +1,65 @@
+// 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 { defineMessages } from 'react-intl'
+import { useSelector } from 'react-redux'
+
+import Message from '@ttn-lw/lib/components/message'
+
+import sharedMessages from '@ttn-lw/lib/shared-messages'
+
+import {
+ selectPerEntityBookmarks,
+ selectPerEntityTotalCount,
+} from '@console/store/selectors/user-preferences'
+
+import EntitiesList from '../list'
+
+const m = defineMessages({
+ emptyMessage: 'No top device yet',
+ emptyDescription: 'Your most visited, and bookmarked end devices will be listed here',
+})
+
+const TopDevicesList = () => {
+ const allBookmarks = useSelector(state => selectPerEntityBookmarks(state, 'device'))
+
+ const headers = [
+ {
+ name: 'name',
+ displayName: sharedMessages.name,
+ render: (name, id) => (
+ <>
+
+ {name && (
+
+ )}
+ >
+ ),
+ },
+ ]
+
+ return (
+
+ )
+}
+
+export default TopDevicesList
diff --git a/pkg/webui/console/containers/top-entities-dashboard-panel/top-entities-panel.styl b/pkg/webui/console/containers/top-entities-dashboard-panel/top-entities-panel.styl
new file mode 100644
index 0000000000..8903cb2fa4
--- /dev/null
+++ b/pkg/webui/console/containers/top-entities-dashboard-panel/top-entities-panel.styl
@@ -0,0 +1,47 @@
+// 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.
+
+.top-entities-panel
+ min-height: 31rem
+ display: flex
+ flex-direction: column
+
+.entity
+ &-list
+ scrollbar-width: none
+ &::-webkit-scrollbar
+ display: none
+ &-gradient
+ position:absolute
+ bottom: 0px
+ left: 0px
+ width: 100%
+ height: 98px
+ border-radius: 0px 0px 14px 14px
+ background: linear-gradient(180deg, rgba(255, 255, 255, .2) 0%, var(--c-bg-neutral-min) 64.58%)
+ pointer-events: none // Prevents the gradient from blocking the click event
+ &-body
+ height: 20rem
+ &-row
+ min-width: 100%
+ display: inline-table
+ border-bottom: 1px solid var(--c-border-neutral-extralight)
+ &.last-row
+ margin-bottom: 56px
+ &-cell
+ width: 33%
+ &-extended
+ width: 100%
+ &-small
+ width: 12%
\ No newline at end of file
diff --git a/pkg/webui/console/containers/top-entities-dashboard-panel/top-gateways/index.js b/pkg/webui/console/containers/top-entities-dashboard-panel/top-gateways/index.js
new file mode 100644
index 0000000000..2f122aac4c
--- /dev/null
+++ b/pkg/webui/console/containers/top-entities-dashboard-panel/top-gateways/index.js
@@ -0,0 +1,68 @@
+// 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 { defineMessages } from 'react-intl'
+import { useSelector } from 'react-redux'
+
+import Message from '@ttn-lw/lib/components/message'
+
+import sharedMessages from '@ttn-lw/lib/shared-messages'
+
+import {
+ selectPerEntityBookmarks,
+ selectPerEntityTotalCount,
+} from '@console/store/selectors/user-preferences'
+
+import EntitiesList from '../list'
+
+const m = defineMessages({
+ emptyMessage: 'No top gateway yet',
+ emptyDescription: 'Your most visited, and bookmarked gateways will be listed here',
+ emptyAction: 'Create gateway',
+})
+
+const TopGatewaysList = () => {
+ const allBookmarks = useSelector(state => selectPerEntityBookmarks(state, 'gateway'))
+
+ const headers = [
+ {
+ name: 'name',
+ displayName: sharedMessages.name,
+ render: (name, id) => (
+ <>
+
+ {name && (
+
+ )}
+ >
+ ),
+ },
+ ]
+
+ return (
+
+ )
+}
+
+export default TopGatewaysList
diff --git a/pkg/webui/console/store/actions/user-preferences.js b/pkg/webui/console/store/actions/user-preferences.js
index ef172fa152..1d832c0f4c 100644
--- a/pkg/webui/console/store/actions/user-preferences.js
+++ b/pkg/webui/console/store/actions/user-preferences.js
@@ -28,6 +28,16 @@ export const [
{ request: getBookmarksList, success: getBookmarksListSuccess, failure: getBookmarksListFailure },
] = createPaginationByIdRequestActions('BOOKMARKS')
+export const GET_ALL_BOOKMARKS_BASE = 'GET_ALL_BOOKMARKS'
+export const [
+ {
+ request: GET_ALL_BOOKMARKS,
+ success: GET_ALL_BOOKMARKS_SUCCESS,
+ failure: GET_ALL_BOOKMARKS_FAILURE,
+ },
+ { request: getAllBookmarks, success: getAllBookmarksSuccess, failure: getAllBookmarksFailure },
+] = createRequestActions(GET_ALL_BOOKMARKS_BASE, id => ({ id }))
+
export const ADD_BOOKMARK_BASE = 'ADD_BOOKMARK'
export const [
{ request: ADD_BOOKMARK, success: ADD_BOOKMARK_SUCCESS, failure: ADD_BOOKMARK_FAILURE },
diff --git a/pkg/webui/console/store/middleware/logics/init.js b/pkg/webui/console/store/middleware/logics/init.js
index 4eb37668ae..038f5d64ce 100644
--- a/pkg/webui/console/store/middleware/logics/init.js
+++ b/pkg/webui/console/store/middleware/logics/init.js
@@ -25,7 +25,7 @@ import {
getInboxNotifications,
getUnseenNotificationsPeriodically,
} from '@console/store/actions/notifications'
-import { getBookmarksList } from '@console/store/actions/user-preferences'
+import { getAllBookmarks } from '@console/store/actions/user-preferences'
const consoleAppLogic = createRequestLogic({
type: init.INITIALIZE,
@@ -72,7 +72,7 @@ const consoleAppLogic = createRequestLogic({
userResult.isAdmin = info.is_admin || false
dispatch(user.getUserMeSuccess(userResult))
dispatch(getInboxNotifications({ page: 1, limit: 3 }))
- dispatch(getBookmarksList(userId, { page: 1, limit: 20 }))
+ dispatch(getAllBookmarks(userId))
dispatch(getUnseenNotificationsPeriodically())
} catch (error) {
dispatch(user.getUserMeFailure(error))
diff --git a/pkg/webui/console/store/middleware/logics/user-preferences.js b/pkg/webui/console/store/middleware/logics/user-preferences.js
index 6c6ef01255..827fdf9652 100644
--- a/pkg/webui/console/store/middleware/logics/user-preferences.js
+++ b/pkg/webui/console/store/middleware/logics/user-preferences.js
@@ -18,16 +18,159 @@ import createRequestLogic from '@ttn-lw/lib/store/logics/create-request-logic'
import * as userPreferences from '@console/store/actions/user-preferences'
+const addBookmarkToResult = (result, entity, element) => {
+ if (!(entity in result.perEntityBookmarks)) {
+ result.perEntityBookmarks[entity] = []
+ result.perEntityBookmarks[entity].push(element)
+ } else {
+ result.perEntityBookmarks[entity].push(element)
+ }
+ if (!(entity in result.totalCount.perEntityTotalCount)) {
+ result.totalCount.perEntityTotalCount[entity] = 1
+ } else {
+ result.totalCount.perEntityTotalCount[entity] += 1
+ }
+
+ return result
+}
+
+const getBookmarksThroughPagination = async userId => {
+ let page = 1
+ const limit = 1000
+ let totalCount = Infinity
+ let result = {
+ bookmarks: [],
+ perEntityBookmarks: {},
+ totalCount: {
+ totalCount: 0,
+ perEntityTotalCount: {},
+ },
+ }
+
+ while ((page - 1) * limit < totalCount) {
+ // Get the next page of bookmarks.
+ // eslint-disable-next-line no-await-in-loop
+ const response = await tts.Users.getBookmarks(userId, { page, limit, order: '-created_at' })
+ response.bookmarks.forEach(element => {
+ const entityIds = element.entity_ids
+ const entity = Object.keys(entityIds)[0].replace('_ids', '')
+ result = addBookmarkToResult(result, entity, element)
+ })
+
+ result = {
+ bookmarks: [...result.bookmarks, ...response.bookmarks],
+ perEntityBookmarks: { ...result.perEntityBookmarks },
+ totalCount: {
+ ...result.totalCount,
+ totalCount: response.totalCount,
+ },
+ }
+
+ totalCount = response.totalCount
+ page += 1
+ }
+
+ return result
+}
+
+const getBookmarksPerEntityThroughPagination = async (
+ userId,
+ requestedEntity,
+ start,
+ end,
+ limit,
+) => {
+ let page = 1
+ let totalCount = limit
+ const result = {
+ perEntityBookmarks: {
+ [requestedEntity]: [],
+ },
+ perEntityTotalCount: {
+ [requestedEntity]: 0,
+ },
+ }
+
+ while (result.perEntityBookmarks[requestedEntity].length < totalCount) {
+ // eslint-disable-next-line no-await-in-loop
+ const response = await tts.Users.getBookmarks(userId, { page, limit, order: '-created_at' })
+ if (totalCount > response.totalCount) {
+ totalCount = response.totalCount
+ }
+
+ response.bookmarks.forEach(element => {
+ const entityIds = element.entity_ids
+ const entity = Object.keys(entityIds)[0].replace('_ids', '')
+ if (entity === requestedEntity) {
+ result = addBookmarkToResult(result, requestedEntity, element)
+ }
+ })
+
+ result.perEntityBookmarks[requestedEntity] = result.perEntityBookmarks[requestedEntity].slice(
+ start,
+ end,
+ )
+
+ page += 1
+ }
+
+ return result
+}
+
const getBookmarksListLogic = createRequestLogic({
type: userPreferences.GET_BOOKMARKS_LIST,
process: async ({ action }) => {
+ const {
+ meta: { selectors },
+ } = action
const {
id: userId,
- params: { page, limit, order, deleted },
+ params: { page, limit, deleted },
} = action.payload
- const data = await tts.Users.getBookmarks(userId, { page, limit, order, deleted })
- return { entities: data.bookmarks, totalCount: data.totalCount }
+ if ('entity' in selectors && selectors.entity !== 'all') {
+ const data = await getBookmarksPerEntityThroughPagination(
+ userId,
+ selectors.entity,
+ selectors.start,
+ selectors.end,
+ limit,
+ )
+
+ return {
+ perEntityBookmarks: data.perEntityBookmarks,
+ perEntityTotalCount: data.perEntityTotalCount,
+ page,
+ limit,
+ entity: selectors.entity,
+ }
+ }
+
+ const data = await tts.Users.getBookmarks(userId, {
+ page,
+ limit,
+ order: '-created_at',
+ deleted,
+ })
+ return {
+ entities: data.bookmarks,
+ totalCount: data.totalCount,
+ page,
+ limit,
+ }
+ },
+})
+
+const getAllBookmarksLogic = createRequestLogic({
+ type: userPreferences.GET_ALL_BOOKMARKS,
+ process: async ({ action }) => {
+ const { id: userId } = action.payload
+ const data = await getBookmarksThroughPagination(userId)
+ return {
+ bookmarks: data.bookmarks,
+ perEntityBookmarks: data.perEntityBookmarks,
+ totalCount: data.totalCount,
+ }
},
})
@@ -53,4 +196,4 @@ const deleteBookmarkLogic = createRequestLogic({
},
})
-export default [getBookmarksListLogic, addBookmarkLogic, deleteBookmarkLogic]
+export default [getBookmarksListLogic, getAllBookmarksLogic, addBookmarkLogic, deleteBookmarkLogic]
diff --git a/pkg/webui/console/store/reducers/notifications.js b/pkg/webui/console/store/reducers/notifications.js
index e5b040a447..5a7590f440 100644
--- a/pkg/webui/console/store/reducers/notifications.js
+++ b/pkg/webui/console/store/reducers/notifications.js
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+import { fillIntoArray, pageToIndices } from '@console/store/utils'
+
import {
GET_ARCHIVED_NOTIFICATIONS_SUCCESS,
GET_INBOX_NOTIFICATIONS_SUCCESS,
@@ -28,22 +30,6 @@ const defaultState = {
unseenTotalCount: undefined,
}
-// Update a range of values in an array by using another array and a start index.
-const fillIntoArray = (array, start, values, totalCount) => {
- const newArray = [...array]
- const end = Math.min(start + values.length, totalCount)
- for (let i = start; i < end; i++) {
- newArray[i] = values[i - start]
- }
- return newArray
-}
-
-const pageToIndices = (page, limit) => {
- const startIndex = (page - 1) * limit
- const stopIndex = page * limit - 1
- return [startIndex, stopIndex]
-}
-
const notifications = (state = defaultState, { type, payload }) => {
switch (type) {
case GET_INBOX_NOTIFICATIONS_SUCCESS:
diff --git a/pkg/webui/console/store/reducers/user-preferences.js b/pkg/webui/console/store/reducers/user-preferences.js
index 4ca500aa8d..e68fce6e92 100644
--- a/pkg/webui/console/store/reducers/user-preferences.js
+++ b/pkg/webui/console/store/reducers/user-preferences.js
@@ -12,28 +12,86 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import { GET_BOOKMARKS_LIST_SUCCESS } from '@console/store/actions/user-preferences'
+import { fillIntoArray, pageToIndices } from '@console/store/utils'
+
+import {
+ ADD_BOOKMARK_SUCCESS,
+ GET_ALL_BOOKMARKS_SUCCESS,
+ GET_BOOKMARKS_LIST_SUCCESS,
+} from '@console/store/actions/user-preferences'
import { GET_USER_ME_SUCCESS } from '@console/store/actions/logout'
const initialState = {
bookmarks: {
bookmarks: [],
- totalCount: 0,
+ totalCount: {},
+ perEntityBookmarks: {},
},
consolePreferences: {},
}
const userPreferences = (state = initialState, { type, payload }) => {
switch (type) {
- case GET_BOOKMARKS_LIST_SUCCESS:
+ case GET_ALL_BOOKMARKS_SUCCESS:
return {
...state,
bookmarks: {
...state.bookmarks,
- bookmarks: payload.entities,
+ bookmarks: payload.bookmarks,
+ perEntityBookmarks: payload.perEntityBookmarks,
totalCount: payload.totalCount,
},
}
+ case GET_BOOKMARKS_LIST_SUCCESS:
+ if ('perEntityBookmarks' in payload) {
+ return {
+ ...state,
+ bookmarks: {
+ ...state.bookmarks,
+ perEntityBookmarks: {
+ ...state.bookmarks.perEntityBookmarks,
+ [payload.entity]: fillIntoArray(
+ state.bookmarks.perEntityBookmarks[payload.entity],
+ pageToIndices(payload.page, payload.limit)[0],
+ payload.perEntityBookmarks[payload.entity],
+ payload.perEntityTotalCount[payload.entity],
+ ),
+ },
+ totalCount: {
+ ...state.bookmarks.totalCount,
+ perEntityTotalCount: payload.perEntityTotalCount,
+ },
+ },
+ }
+ }
+
+ return {
+ ...state,
+ bookmarks: {
+ ...state.bookmarks,
+ bookmarks: fillIntoArray(
+ state.bookmarks.bookmarks,
+ pageToIndices(payload.page, payload.limit)[0],
+ payload.entities,
+ payload.totalCount,
+ ),
+ totalCount: {
+ ...state.bookmarks.totalCount,
+ totalCount: payload.totalCount,
+ },
+ },
+ }
+ case ADD_BOOKMARK_SUCCESS:
+ return {
+ ...state,
+ bookmarks: {
+ ...state.bookmarks,
+ totalCount: {
+ ...state.bookmarks.totalCount,
+ totalCount: state.bookmarks.totalCount.totalCount + 1,
+ },
+ },
+ }
case GET_USER_ME_SUCCESS:
return {
...state,
diff --git a/pkg/webui/console/store/selectors/user-preferences.js b/pkg/webui/console/store/selectors/user-preferences.js
index 008cf112da..0a667fc714 100644
--- a/pkg/webui/console/store/selectors/user-preferences.js
+++ b/pkg/webui/console/store/selectors/user-preferences.js
@@ -19,5 +19,11 @@ export const selectConsolePreferences = state =>
export const selectBookmarksList = state => selectUserPreferencesStore(state).bookmarks.bookmarks
+export const selectPerEntityBookmarks = (state, entity) =>
+ selectUserPreferencesStore(state).bookmarks?.perEntityBookmarks[entity] || []
+
export const selectBookmarksTotalCount = state =>
- selectUserPreferencesStore(state).bookmarks.totalCount
+ selectUserPreferencesStore(state).bookmarks.totalCount.totalCount
+
+export const selectPerEntityTotalCount = (state, entity) =>
+ selectUserPreferencesStore(state).bookmarks.totalCount.perEntityTotalCount[entity] || 0
diff --git a/pkg/webui/console/store/utils.js b/pkg/webui/console/store/utils.js
new file mode 100644
index 0000000000..e40abdedb8
--- /dev/null
+++ b/pkg/webui/console/store/utils.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.
+
+// Update a range of values in an array by using another array and a start index.
+export const fillIntoArray = (array, start, values, totalCount) => {
+ const newArray = [...array]
+ const end = Math.min(start + values.length, totalCount)
+ for (let i = start; i < end; i++) {
+ newArray[i] = values[i - start]
+ }
+
+ return newArray
+}
+
+export const pageToIndices = (page, limit) => {
+ const startIndex = (page - 1) * limit
+ const stopIndex = page * limit - 1
+
+ return [startIndex, stopIndex]
+}
diff --git a/pkg/webui/console/views/application/index.js b/pkg/webui/console/views/application/index.js
index e873db883d..8426d0f157 100644
--- a/pkg/webui/console/views/application/index.js
+++ b/pkg/webui/console/views/application/index.js
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import React, { useEffect } from 'react'
+import React, { useEffect, useMemo } from 'react'
import { Routes, Route, useParams } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
@@ -56,14 +56,17 @@ import {
const Application = () => {
const { appId } = useParams()
- const actions = [
- getApplication(
- appId,
- 'name,description,attributes,dev_eui_counter,network_server_address,application_server_address,join_server_address,administrative_contact,technical_contact',
- ),
- getApplicationsRightsList(appId),
- getAsConfiguration(),
- ]
+ const actions = useMemo(
+ () => [
+ getApplication(
+ appId,
+ 'name,description,attributes,dev_eui_counter,network_server_address,application_server_address,join_server_address,administrative_contact,technical_contact',
+ ),
+ getApplicationsRightsList(appId),
+ getAsConfiguration(),
+ ],
+ [appId],
+ )
// Check whether application still exists after it has been possibly deleted.
const application = useSelector(selectSelectedApplication)
@@ -71,7 +74,7 @@ const Application = () => {
return (
-
+
{hasApplication && }
@@ -90,7 +93,10 @@ const ApplicationInner = () => {
const stopStream = React.useCallback(id => dispatch(stopApplicationEventsStream(id)), [dispatch])
useEffect(() => () => stopStream(appId), [appId, stopStream])
- useBreadcrumbs('apps.single',
)
+ useBreadcrumbs(
+ `apps.single#${appId}`,
+
,
+ )
return (
<>
diff --git a/pkg/webui/console/views/overview/index.js b/pkg/webui/console/views/overview/index.js
index a636b30307..dd34d13928 100644
--- a/pkg/webui/console/views/overview/index.js
+++ b/pkg/webui/console/views/overview/index.js
@@ -22,6 +22,7 @@ import RequireRequest from '@ttn-lw/lib/components/require-request'
import ShortcutPanel from '@console/containers/shortcut-panel'
import NotificationsDashboardPanel from '@console/containers/notifications-dashboard-panel'
import DocumentationDashboardPanel from '@console/containers/documentation-dashboard-panel'
+import TopEntitiesDashboardPanel from '@console/containers/top-entities-dashboard-panel'
import sharedMessages from '@ttn-lw/lib/shared-messages'
@@ -34,7 +35,9 @@ const Overview = () => {
return (
-
+
+
+
diff --git a/pkg/webui/containers/fetch-table/index.js b/pkg/webui/containers/fetch-table/index.js
index 1be5329ba7..1489ac5320 100644
--- a/pkg/webui/containers/fetch-table/index.js
+++ b/pkg/webui/containers/fetch-table/index.js
@@ -310,7 +310,7 @@ const FetchTable = props => {
paginated={paginated}
page={page}
totalCount={totalCount}
- pageSize={queryPageSize ?? pageSize}
+ pageSize={parseInt(queryPageSize ?? pageSize)}
setPageSize={setPageSize}
onPageChange={onPageChange}
loading={fetching}
diff --git a/pkg/webui/lib/hooks/use-bookmark.js b/pkg/webui/lib/hooks/use-bookmark.js
index 54e8f2878c..9a6598f2ae 100644
--- a/pkg/webui/lib/hooks/use-bookmark.js
+++ b/pkg/webui/lib/hooks/use-bookmark.js
@@ -15,6 +15,15 @@
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
+import {
+ IconApplication,
+ IconGateway,
+ IconOrganization,
+ IconUser,
+ IconOauthClients,
+ IconDevice,
+} from '@ttn-lw/components/icon'
+
import attachPromise from '@ttn-lw/lib/store/actions/attach-promise'
import { getApplication } from '@console/store/actions/applications'
@@ -32,12 +41,12 @@ import { selectClientById } from '@account/store/selectors/clients'
import { selectDeviceByIds } from '@console/store/selectors/devices'
const iconMap = {
- application: 'application',
- gateway: 'gateway',
- organization: 'organization',
- user: 'user',
- client: 'client',
- device: 'device',
+ application: IconApplication,
+ gateway: IconGateway,
+ organization: IconOrganization,
+ user: IconUser,
+ client: IconOauthClients,
+ device: IconDevice,
}
const entityRequestMap = {
@@ -93,14 +102,14 @@ const useBookmark = bookmark => {
} else {
response = await dispatch(attachPromise(entityRequestMap[entity](entityId.id, 'name')))
}
- setBookmarkTitle(response.name || entityIds[`${entity}_ids`][`${entity}_id`])
+ setBookmarkTitle(response.name || '')
}
// Only fetch the entity if the name is not already in the store.
if (!bookmarkTitle) {
fetchEntity()
}
- }, [entity, entityId, dispatch, entityIds, bookmarkTitle])
+ }, [bookmarkTitle, dispatch, entity, entityId.appId, entityId.id])
// Get the icon corresponding to the entity.
const icon = iconMap[entity]
@@ -108,9 +117,9 @@ const useBookmark = bookmark => {
const path =
entity === 'device'
? `/applications/${entityIds.device_ids.application_ids.application_id}/devices/${entityIds.device_ids.device_id}`
- : `/${entity}s/${entityIds[`${entity}_ids`][`${entity}_id`]}`
+ : `/${entity}s/${entityId.id}`
- return { title: bookmarkTitle ?? '', path, icon }
+ return { title: bookmarkTitle ?? 'Fetching bookmark...', ids: entityId, path, icon }
}
export default useBookmark
diff --git a/pkg/webui/lib/hooks/use-request.js b/pkg/webui/lib/hooks/use-request.js
index 7f6a95f3d1..511a1ec54e 100644
--- a/pkg/webui/lib/hooks/use-request.js
+++ b/pkg/webui/lib/hooks/use-request.js
@@ -33,6 +33,10 @@ const useRequest = (requestAction, requestOnChange) => {
? requestAction(dispatch)
: dispatch(attachPromise(requestAction))
+ if (requestOnChange) {
+ setFetching(true)
+ }
+
promise
.then(result => {
setResult(result)
diff --git a/pkg/webui/lib/shared-messages.js b/pkg/webui/lib/shared-messages.js
index e9d37d573a..f6edc05a8d 100644
--- a/pkg/webui/lib/shared-messages.js
+++ b/pkg/webui/lib/shared-messages.js
@@ -29,6 +29,9 @@ export default defineMessages({
actions: 'Actions',
activationMode: 'Activation mode',
add: 'Add',
+ addApplication: 'Add application',
+ addGateway: 'Add new gateway',
+ addOrganization: 'Add new organization',
addApiKey: 'Add API key',
addAttributes: 'Add attributes',
addCollaborator: 'Add collaborator',
@@ -186,6 +189,7 @@ export default defineMessages({
deviceNamePlaceholder: 'My new end device',
deviceSimulationDisabledWarning: 'Simulation is disabled for devices that skip payload crypto',
devices: 'End devices',
+ devicesShort: 'Devices',
disabled: 'Disabled',
disconnected: 'Disconnected',
documentation: 'Documentation',
@@ -476,6 +480,8 @@ export default defineMessages({
settings: 'Settings',
setup: 'Setup',
shareGatewayInfo: 'Share gateway information',
+ showMore: 'Show more',
+ showLess: 'Show less',
skipCryptoDescription: 'Skip decryption of uplink payloads and encryption of downlink payloads',
skipCryptoPlaceholder: 'Encryption/decryption disabled',
skipCryptoTitle: 'Skip payload encryption and decryption',
diff --git a/pkg/webui/locales/en.json b/pkg/webui/locales/en.json
index ff92c01a0b..226a35142e 100644
--- a/pkg/webui/locales/en.json
+++ b/pkg/webui/locales/en.json
@@ -573,9 +573,6 @@
"console.containers.header.bookmarks-dropdown.noBookmarks": "No bookmarks yet",
"console.containers.header.bookmarks-dropdown.noBookmarksDescription": "Your bookmarked entities will be listed here",
"console.containers.header.bookmarks-dropdown.threshold": "Only showing latest 15 bookmarks",
- "console.containers.header.index.addApplication": "Add new application",
- "console.containers.header.index.addGateway": "Add new gateway",
- "console.containers.header.index.addOrganization": "Add new organization",
"console.containers.header.notifications-dropdown.description": "Showing last 3 of {totalNotifications} notifications",
"console.containers.header.notifications-dropdown.noNotifications": "All caught up!",
"console.containers.header.notifications-dropdown.noNotificationsDescription": "You don’t have any notifications currently",
@@ -637,13 +634,25 @@
"console.containers.pubsub-formats-select.index.warning": "Pub/Sub formats unavailable",
"console.containers.pubsubs-table.index.host": "Server host",
"console.containers.shortcut-panel.index.shortcuts": "Quick actions",
- "console.containers.shortcut-panel.index.addApplication": "New Application",
- "console.containers.shortcut-panel.index.addGateway": "New Gateway",
- "console.containers.shortcut-panel.index.addOrganization": "New Organization",
+ "console.containers.shortcut-panel.index.addApplication": "New application",
+ "console.containers.shortcut-panel.index.addGateway": "New gateway",
+ "console.containers.shortcut-panel.index.addNewOrganization": "New organization",
"console.containers.shortcut-panel.index.addPersonalApiKey": "New personal API key",
- "console.containers.shortcut-panel.index.registerDevice": "Register a device",
+ "console.containers.shortcut-panel.index.registerDevice": "Register an end device",
"console.containers.sidebar.navigation.app-side-navigation.buttonMessage": "Back to Applications list",
"console.containers.sidebar.navigation.gtw-side-navigation.buttonMessage": "Back to Gateways list",
+ "console.containers.top-entities-dashboard-panel.all-top-entities.index.noTopEntities": "No top entities yet",
+ "console.containers.top-entities-dashboard-panel.all-top-entities.index.noTopEntitiesDescription": "Your most visited, and bookmarked entities will be listed here.",
+ "console.containers.top-entities-dashboard-panel.index.title": "Your top entities",
+ "console.containers.top-entities-dashboard-panel.list.empty": "No entities yet",
+ "console.containers.top-entities-dashboard-panel.top-applications.index.emptyMessage": "No top application yet",
+ "console.containers.top-entities-dashboard-panel.top-applications.index.emptyDescription": "Your most visited, and bookmarked applications will be listed here",
+ "console.containers.top-entities-dashboard-panel.top-applications.item.errorMessage": "Not available",
+ "console.containers.top-entities-dashboard-panel.top-devices.index.emptyMessage": "No top device yet",
+ "console.containers.top-entities-dashboard-panel.top-devices.index.emptyDescription": "Your most visited, and bookmarked end devices will be listed here",
+ "console.containers.top-entities-dashboard-panel.top-gateways.index.emptyMessage": "No top gateway yet",
+ "console.containers.top-entities-dashboard-panel.top-gateways.index.emptyDescription": "Your most visited, and bookmarked gateways will be listed here",
+ "console.containers.top-entities-dashboard-panel.top-gateways.index.emptyAction": "Create gateway",
"console.containers.user-data-form.edit.deleteWarning": "This will
PERMANENTLY DELETE THIS ACCOUNT and
LOCK THE USER ID AND EMAIL FOR RE-REGISTRATION. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become
UNACCESSIBLE and it will
NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.",
"console.containers.user-data-form.edit.purgeWarning": "This will
PERMANENTLY DELETE THIS ACCOUNT. Associated entities (e.g. gateways, applications and end devices) owned by this user that do not have any other collaborators will become
UNACCESSIBLE and it will
NOT BE POSSIBLE TO REGISTER ENTITIES WITH THE SAME ID OR EUI's AGAIN. Make sure you assign new collaborators to such entities if you plan to continue using them.",
"console.containers.user-data-form.edit.deleteConfirmMessage": "Please type in this user's user ID to confirm.",
@@ -1033,6 +1042,9 @@
"lib.shared-messages.actions": "Actions",
"lib.shared-messages.activationMode": "Activation mode",
"lib.shared-messages.add": "Add",
+ "lib.shared-messages.addApplication": "Add application",
+ "lib.shared-messages.addGateway": "Add new gateway",
+ "lib.shared-messages.addOrganization": "Add new organization",
"lib.shared-messages.addApiKey": "Add API key",
"lib.shared-messages.addAttributes": "Add attributes",
"lib.shared-messages.addCollaborator": "Add collaborator",
@@ -1174,6 +1186,7 @@
"lib.shared-messages.deviceNamePlaceholder": "My new end device",
"lib.shared-messages.deviceSimulationDisabledWarning": "Simulation is disabled for devices that skip payload crypto",
"lib.shared-messages.devices": "End devices",
+ "lib.shared-messages.devicesShort": "Devices",
"lib.shared-messages.disabled": "Disabled",
"lib.shared-messages.disconnected": "Disconnected",
"lib.shared-messages.documentation": "Documentation",
@@ -1446,6 +1459,8 @@
"lib.shared-messages.settings": "Settings",
"lib.shared-messages.setup": "Setup",
"lib.shared-messages.shareGatewayInfo": "Share gateway information",
+ "lib.shared-messages.showMore": "Show more",
+ "lib.shared-messages.showLess": "Show less",
"lib.shared-messages.skipCryptoDescription": "Skip decryption of uplink payloads and encryption of downlink payloads",
"lib.shared-messages.skipCryptoPlaceholder": "Encryption/decryption disabled",
"lib.shared-messages.skipCryptoTitle": "Skip payload encryption and decryption",
diff --git a/pkg/webui/locales/ja.json b/pkg/webui/locales/ja.json
index d18809e542..c52ff79f19 100644
--- a/pkg/webui/locales/ja.json
+++ b/pkg/webui/locales/ja.json
@@ -145,7 +145,7 @@
"components.key-value-map.index.addEntry": "エントリー追加",
"components.link.index.glossaryTitle": "用語集「{term}」を参照してください",
"components.link.index.defaultGlossaryTitle": "用語集を見る",
- "components.mobile-menu.index.loggedInAs": "
{userId}でログインしました",
+ "components.mobile-menu.index.loggedInAs": "",
"components.notification.details.index.showDetails": "詳細を表示",
"components.notification.details.index.details": "詳細",
"components.notification.details.index.errorDetails": "エラー詳細",
@@ -573,9 +573,6 @@
"console.containers.header.bookmarks-dropdown.noBookmarks": "",
"console.containers.header.bookmarks-dropdown.noBookmarksDescription": "",
"console.containers.header.bookmarks-dropdown.threshold": "",
- "console.containers.header.index.addApplication": "",
- "console.containers.header.index.addGateway": "",
- "console.containers.header.index.addOrganization": "",
"console.containers.header.notifications-dropdown.description": "",
"console.containers.header.notifications-dropdown.noNotifications": "",
"console.containers.header.notifications-dropdown.noNotificationsDescription": "",
@@ -639,11 +636,23 @@
"console.containers.shortcut-panel.index.shortcuts": "",
"console.containers.shortcut-panel.index.addApplication": "",
"console.containers.shortcut-panel.index.addGateway": "",
- "console.containers.shortcut-panel.index.addOrganization": "",
+ "console.containers.shortcut-panel.index.addNewOrganization": "",
"console.containers.shortcut-panel.index.addPersonalApiKey": "",
"console.containers.shortcut-panel.index.registerDevice": "",
"console.containers.sidebar.navigation.app-side-navigation.buttonMessage": "",
"console.containers.sidebar.navigation.gtw-side-navigation.buttonMessage": "",
+ "console.containers.top-entities-dashboard-panel.all-top-entities.index.noTopEntities": "",
+ "console.containers.top-entities-dashboard-panel.all-top-entities.index.noTopEntitiesDescription": "",
+ "console.containers.top-entities-dashboard-panel.index.title": "",
+ "console.containers.top-entities-dashboard-panel.list.empty": "",
+ "console.containers.top-entities-dashboard-panel.top-applications.index.emptyMessage": "",
+ "console.containers.top-entities-dashboard-panel.top-applications.index.emptyDescription": "",
+ "console.containers.top-entities-dashboard-panel.top-applications.item.errorMessage": "",
+ "console.containers.top-entities-dashboard-panel.top-devices.index.emptyMessage": "",
+ "console.containers.top-entities-dashboard-panel.top-devices.index.emptyDescription": "",
+ "console.containers.top-entities-dashboard-panel.top-gateways.index.emptyMessage": "",
+ "console.containers.top-entities-dashboard-panel.top-gateways.index.emptyDescription": "",
+ "console.containers.top-entities-dashboard-panel.top-gateways.index.emptyAction": "",
"console.containers.user-data-form.edit.deleteWarning": "",
"console.containers.user-data-form.edit.purgeWarning": "",
"console.containers.user-data-form.edit.deleteConfirmMessage": "",
@@ -1033,6 +1042,9 @@
"lib.shared-messages.actions": "アクション",
"lib.shared-messages.activationMode": "アクティベーションモード",
"lib.shared-messages.add": "追加",
+ "lib.shared-messages.addApplication": "",
+ "lib.shared-messages.addGateway": "",
+ "lib.shared-messages.addOrganization": "",
"lib.shared-messages.addApiKey": "APIキーの追加",
"lib.shared-messages.addAttributes": "属性の追加",
"lib.shared-messages.addCollaborator": "コラボレータ―の追加",
@@ -1174,6 +1186,7 @@
"lib.shared-messages.deviceNamePlaceholder": "私の新しいエンドデバイス",
"lib.shared-messages.deviceSimulationDisabledWarning": "ペイロード暗号をスキップする機器では、シミュレーションは無効になります",
"lib.shared-messages.devices": "エンドデバイス",
+ "lib.shared-messages.devicesShort": "",
"lib.shared-messages.disabled": "無効化",
"lib.shared-messages.disconnected": "切断されます",
"lib.shared-messages.documentation": "ドキュメンテーション",
@@ -1446,6 +1459,8 @@
"lib.shared-messages.settings": "設定",
"lib.shared-messages.setup": "",
"lib.shared-messages.shareGatewayInfo": "ゲートウェイ情報を共有",
+ "lib.shared-messages.showMore": "",
+ "lib.shared-messages.showLess": "",
"lib.shared-messages.skipCryptoDescription": "アップリンクペイロードの復号化とダウンリンクペイロードの暗号化をスキップします",
"lib.shared-messages.skipCryptoPlaceholder": "暗号化/復号化無効",
"lib.shared-messages.skipCryptoTitle": "ペイロードの暗号化と復号化をスキップ",