diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index bd6976e91b..115d872f8d 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -22,13 +22,17 @@ export default async function createPlugin( env: PluginEnvironment, ): Promise { // TODO: Remove this test code + let notifications = 0; setInterval(() => { - env.notificationService.send( - 'user:default/guest', - 'Test', - 'This is test notification', - '/catalog', - ); + if (notifications < 10) { + env.notificationService.send({ + entityRef: 'user:default/guest', + title: 'Test', + description: 'This is test notification', + link: '/catalog', + }); + notifications++; + } }, 60000); return await createRouter({ diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 482a987638..06a702f9d1 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -70,7 +70,49 @@ export async function createRouter( res.send(status); }); - // TODO: Add endpoint to set read/unread by notification id(s) + router.post('/read', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markRead({ user_ref: user, ids }); + res.status(200).send({ ids }); + }); + + router.post('/unread', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markUnread({ user_ref: user, ids }); + res.status(200).send({ ids }); + }); + + router.post('/save', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markSaved({ user_ref: user, ids }); + res.status(200).send({ ids }); + }); + + router.post('/unsave', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markUnsaved({ user_ref: user, ids }); + res.status(200).send({ ids }); + }); router.use(errorHandler()); return router; diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index f8c5a009bc..b33edf3a80 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -11,11 +11,18 @@ type Notification_2 = { description: string; link: string; icon?: string; + image?: string; created: Date; read?: Date; + saved: boolean; }; export { Notification_2 as Notification }; +// @public (undocumented) +export type NotificationIds = { + ids: string[]; +}; + // @public (undocumented) export type NotificationStatus = { unread: number; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index d95ad92508..39dab24681 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -26,8 +26,10 @@ export type Notification = { link: string; // TODO: Icon should be typed so that we know what to render icon?: string; + image?: string; created: Date; read?: Date; + saved: boolean; }; /** @public */ @@ -35,3 +37,8 @@ export type NotificationStatus = { unread: number; read: number; }; + +/** @public */ +export type NotificationIds = { + ids: string[]; +}; diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index c7b6383c6f..31a2bbf3da 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -28,6 +28,14 @@ export class DatabaseNotificationsStore implements NotificationsStore { read: number; }>; // (undocumented) + markRead(options: NotificationModifyOptions): Promise; + // (undocumented) + markSaved(options: NotificationModifyOptions): Promise; + // (undocumented) + markUnread(options: NotificationModifyOptions): Promise; + // (undocumented) + markUnsaved(options: NotificationModifyOptions): Promise; + // (undocumented) saveNotification(notification: Notification_2): Promise; } @@ -37,6 +45,21 @@ export type NotificationGetOptions = { type?: NotificationType; }; +// @public (undocumented) +export type NotificationModifyOptions = { + ids: string[]; +} & NotificationGetOptions; + +// @public (undocumented) +export type NotificationSendOptions = { + entityRef: string | string[]; + title: string; + description: string; + link: string; + image?: string; + icon?: string; +}; + // @public (undocumented) export class NotificationService { // (undocumented) @@ -47,12 +70,7 @@ export class NotificationService { // (undocumented) getStore(): Promise; // (undocumented) - send( - entityRef: string | string[], - title: string, - description: string, - link: string, - ): Promise; + send(options: NotificationSendOptions): Promise; } // @public (undocumented) @@ -71,6 +89,14 @@ export interface NotificationsStore { // (undocumented) getStatus(options: NotificationGetOptions): Promise; // (undocumented) + markRead(options: NotificationModifyOptions): Promise; + // (undocumented) + markSaved(options: NotificationModifyOptions): Promise; + // (undocumented) + markUnread(options: NotificationModifyOptions): Promise; + // (undocumented) + markUnsaved(options: NotificationModifyOptions): Promise; + // (undocumented) saveNotification(notification: Notification_2): Promise; } ``` diff --git a/plugins/notifications-node/migrations/20231215_init.js b/plugins/notifications-node/migrations/20231215_init.js index a76a8c3cbd..e65708a048 100644 --- a/plugins/notifications-node/migrations/20231215_init.js +++ b/plugins/notifications-node/migrations/20231215_init.js @@ -21,8 +21,11 @@ exports.up = async function up(knex) { table.string('title').notNullable(); table.text('description').notNullable(); table.text('link').notNullable(); + table.text('icon').nullable(); + table.text('image').nullable(); table.datetime('created').notNullable(); table.datetime('read').nullable(); + table.boolean('saved').defaultTo(false).notNullable(); }); }; diff --git a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts index 63f9685e85..836bb50022 100644 --- a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts @@ -19,6 +19,7 @@ import { } from '@backstage/backend-common'; import { NotificationGetOptions, + NotificationModifyOptions, NotificationsStore, } from './NotificationsStore'; import { Notification } from '@backstage/plugin-notifications-common'; @@ -55,7 +56,9 @@ export class DatabaseNotificationsStore implements NotificationsStore { return typeof val === 'string' ? Number.parseInt(val, 10) : val ?? 0; }; - private getNotificationsBaseQuery = (options: NotificationGetOptions) => { + private getNotificationsBaseQuery = ( + options: NotificationGetOptions | NotificationModifyOptions, + ) => { const { user_ref, type } = options; const query = this.db('notifications').where('userRef', user_ref); @@ -63,8 +66,14 @@ export class DatabaseNotificationsStore implements NotificationsStore { query.whereNull('read'); } else if (type === 'read') { query.whereNotNull('read'); + } else if (type === 'saved') { + query.where('saved', true); } - // TODO: Saved + + if ('ids' in options && options.ids) { + query.whereIn('id', options.ids); + } + return query; }; @@ -96,4 +105,24 @@ export class DatabaseNotificationsStore implements NotificationsStore { read: this.mapToInteger((readQuery as any)?.READ), }; } + + async markRead(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ read: new Date() }); + } + + async markUnread(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ read: null }); + } + + async markSaved(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ saved: true }); + } + + async markUnsaved(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ saved: false }); + } } diff --git a/plugins/notifications-node/src/database/NotificationsStore.ts b/plugins/notifications-node/src/database/NotificationsStore.ts index b25d32900a..12397621df 100644 --- a/plugins/notifications-node/src/database/NotificationsStore.ts +++ b/plugins/notifications-node/src/database/NotificationsStore.ts @@ -26,6 +26,11 @@ export type NotificationGetOptions = { type?: NotificationType; }; +/** @public */ +export type NotificationModifyOptions = { + ids: string[]; +} & NotificationGetOptions; + /** @public */ export interface NotificationsStore { getNotifications(options: NotificationGetOptions): Promise; @@ -34,5 +39,11 @@ export interface NotificationsStore { getStatus(options: NotificationGetOptions): Promise; - // TODO: Mark as read/unread by notification id(s) + markRead(options: NotificationModifyOptions): Promise; + + markUnread(options: NotificationModifyOptions): Promise; + + markSaved(options: NotificationModifyOptions): Promise; + + markUnsaved(options: NotificationModifyOptions): Promise; } diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index 55dcff546f..501f799d31 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -36,6 +36,16 @@ export type NotificationServiceOptions = { discovery: PluginEndpointDiscovery; }; +/** @public */ +export type NotificationSendOptions = { + entityRef: string | string[]; + title: string; + description: string; + link: string; + image?: string; + icon?: string; +}; + /** @public */ export class NotificationService { private store: NotificationsStore | null = null; @@ -56,12 +66,8 @@ export class NotificationService { return new NotificationService(database, catalogClient); } - async send( - entityRef: string | string[], - title: string, - description: string, - link: string, - ): Promise { + async send(options: NotificationSendOptions): Promise { + const { entityRef, title, description, link, icon, image } = options; const users = await this.getUsersForEntityRef(entityRef); const notifications = []; const store = await this.getStore(); @@ -73,6 +79,9 @@ export class NotificationService { description, link, created: new Date(), + icon, + image, + saved: false, }; await store.saveNotification(notification); diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index 9039f688eb..b4cb4c6c3e 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -11,6 +11,7 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { FetchApi } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { NotificationIds } from '@backstage/plugin-notifications-common'; import { NotificationStatus } from '@backstage/plugin-notifications-common'; import { NotificationType } from '@backstage/plugin-notifications-common'; import { default as React_2 } from 'react'; @@ -29,6 +30,14 @@ export interface NotificationsApi { ): Promise; // (undocumented) getStatus(): Promise; + // (undocumented) + markRead(ids: string[]): Promise; + // (undocumented) + markSaved(ids: string[]): Promise; + // (undocumented) + markUnread(ids: string[]): Promise; + // (undocumented) + markUnsaved(ids: string[]): Promise; } // @public (undocumented) @@ -43,6 +52,14 @@ export class NotificationsClient implements NotificationsApi { ): Promise; // (undocumented) getStatus(): Promise; + // (undocumented) + markRead(ids: string[]): Promise; + // (undocumented) + markSaved(ids: string[]): Promise; + // (undocumented) + markUnread(ids: string[]): Promise; + // (undocumented) + markUnsaved(ids: string[]): Promise; } // @public (undocumented) @@ -61,6 +78,9 @@ export const NotificationsSidebarItem: () => React_2.JSX.Element; // @public (undocumented) export const NotificationsTable: (props: { + onUpdate: () => void; + type: NotificationType; + loading?: boolean; notifications?: Notification_2[]; }) => React_2.JSX.Element; diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index 9e78fc32de..5d7abcf148 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -31,6 +31,7 @@ "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", + "react-relative-time": "^0.0.9", "react-use": "^17.2.4" }, "peerDependencies": { diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts index aa4b460cb3..276bc02ded 100644 --- a/plugins/notifications/src/api/NotificationsApi.ts +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -16,6 +16,7 @@ import { createApiRef } from '@backstage/core-plugin-api'; import { Notification, + NotificationIds, NotificationStatus, NotificationType, } from '@backstage/plugin-notifications-common'; @@ -36,6 +37,11 @@ export interface NotificationsApi { getStatus(): Promise; - // TODO: Mark as read/unread by notification id(s) - // TODO: Mark as saved/unsaved by notification id(s) + markRead(ids: string[]): Promise; + + markUnread(ids: string[]): Promise; + + markSaved(ids: string[]): Promise; + + markUnsaved(ids: string[]): Promise; } diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index ec6472c37e..113aefddca 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -18,6 +18,7 @@ import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { Notification, + NotificationIds, NotificationStatus, } from '@backstage/plugin-notifications-common'; @@ -44,18 +45,50 @@ export class NotificationsClient implements NotificationsApi { const urlSegment = `notifications?${queryString}`; - return await this.get(urlSegment); + return await this.request(urlSegment); } async getStatus(): Promise { - return await this.get('status'); + return await this.request('status'); } - private async get(path: string): Promise { + async markRead(ids: string[]): Promise { + return await this.request('read', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async markUnread(ids: string[]): Promise { + return await this.request('unread', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async markSaved(ids: string[]): Promise { + return await this.request('save', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async markUnsaved(ids: string[]): Promise { + return await this.request('unsave', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + private async request(path: string, init?: any): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('notifications')}/`; const url = new URL(path, baseUrl); - const response = await this.fetchApi.fetch(url.toString()); + const response = await this.fetchApi.fetch(url.toString(), init); if (!response.ok) { throw await ResponseError.fromResponse(response); diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index 461c39e2aa..eeaec21ef1 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -44,12 +44,14 @@ const useStyles = makeStyles(_theme => ({ export const NotificationsPage = () => { const [type, setType] = useState('unread'); - const { - loading: _loading, - error, - value, - retry: _retry, - } = useNotificationsApi(api => api.getNotifications({ type }), [type]); + const { loading, error, value, retry } = useNotificationsApi( + api => api.getNotifications({ type }), + [type], + ); + + const onUpdate = () => { + retry(); + }; const styles = useStyles(); if (error) { @@ -89,7 +91,12 @@ export const NotificationsPage = () => { - + diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index 6e990cf3ea..9ccc76e1ec 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { + Box, + Button, IconButton, makeStyles, Table, @@ -24,20 +26,45 @@ import { Tooltip, Typography, } from '@material-ui/core'; -import { Notification } from '@backstage/plugin-notifications-common'; +import { + Notification, + NotificationType, +} from '@backstage/plugin-notifications-common'; import { useNavigate } from 'react-router-dom'; import NotificationsIcon from '@material-ui/icons/Notifications'; import Checkbox from '@material-ui/core/Checkbox'; import Check from '@material-ui/icons/Check'; import Bookmark from '@material-ui/icons/Bookmark'; +import { notificationsApiRef } from '../../api'; +import { useApi } from '@backstage/core-plugin-api'; +import Inbox from '@material-ui/icons/Inbox'; +import CloseIcon from '@material-ui/icons/Close'; +import { Skeleton } from '@material-ui/lab'; +// @ts-ignore +import RelativeTime from 'react-relative-time'; const useStyles = makeStyles(theme => ({ notificationRow: { cursor: 'pointer', + '&.hideOnHover': { + display: 'initial', + }, + '& .showOnHover': { + display: 'none', + }, '&:hover': { backgroundColor: theme.palette.linkHover, + '& .hideOnHover': { + display: 'none', + }, + '& .showOnHover': { + display: 'initial', + }, }, }, + actionButton: { + padding: '9px', + }, checkBox: { padding: '0 10px 10px 0', }, @@ -45,25 +72,102 @@ const useStyles = makeStyles(theme => ({ /** @public */ export const NotificationsTable = (props: { + onUpdate: () => void; + type: NotificationType; + loading?: boolean; notifications?: Notification[]; }) => { - const { notifications } = props; + const { notifications, type, loading } = props; const navigate = useNavigate(); const styles = useStyles(); - // TODO: Add select all - // TODO: Make mark as read work - // TODO: Check status of the notification and change to "Mark as unread" if it's already read - // TODO: Add support to save notifications (storageApi) + const [selected, setSelected] = useState([]); + const notificationsApi = useApi(notificationsApiRef); + + const onCheckBoxClick = (id: string) => { + const index = selected.indexOf(id); + if (index !== -1) { + setSelected(selected.filter(s => s !== id)); + } else { + setSelected([...selected, id]); + } + }; + + useEffect(() => { + setSelected([]); + }, [type]); + + const isChecked = (id: string) => { + return selected.indexOf(id) !== -1; + }; + + const isAllSelected = () => { + return ( + selected.length === notifications?.length && notifications.length > 0 + ); + }; + + if (loading) { + return ; + } + // TODO: Show timestamp relative time (react-relative-time npm package) // TODO: Add signals listener and refresh data on message - // TODO: Handle no notifications properly - // TODO: Handle loading notifications return ( - {notifications?.length ?? 0} notifications + {type !== 'saved' && !notifications?.length && 'No notifications'} + {type !== 'saved' && !!notifications?.length && ( + { + if (isAllSelected()) { + setSelected([]); + } else { + setSelected( + notifications ? notifications.map(n => n.id) : [], + ); + } + }} + /> + )} + {type === 'saved' && + `${notifications?.length ?? 0} saved notifications`} + {selected.length === 0 && + !!notifications?.length && + type !== 'saved' && + 'Select all'} + {selected.length > 0 && `${selected.length} selected`} + {type === 'read' && selected.length > 0 && ( + + )} + + {type === 'unread' && selected.length > 0 && ( + + )} @@ -71,7 +175,12 @@ export const NotificationsTable = (props: { return ( - + onCheckBoxClick(notification.id)} + /> {notification.icon ?? } navigate(notification.link)}> @@ -81,16 +190,67 @@ export const NotificationsTable = (props: { - - - - - - - - - - + + + + + + { + if (notification.read) { + notificationsApi + .markUnread([notification.id]) + .then(() => { + props.onUpdate(); + }); + } else { + notificationsApi + .markRead([notification.id]) + .then(() => { + props.onUpdate(); + }); + } + }} + > + {notification.read ? ( + + ) : ( + + )} + + + + { + if (notification.saved) { + notificationsApi + .markUnsaved([notification.id]) + .then(() => { + props.onUpdate(); + }); + } else { + notificationsApi + .markSaved([notification.id]) + .then(() => { + props.onUpdate(); + }); + } + }} + > + {notification.saved ? ( + + ) : ( + + )} + + + ); diff --git a/yarn.lock b/yarn.lock index 593901c68e..62519ba3a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7845,6 +7845,7 @@ __metadata: "@testing-library/react": ^12.1.3 "@testing-library/user-event": ^14.0.0 msw: ^1.0.0 + react-relative-time: ^0.0.9 react-use: ^17.2.4 peerDependencies: react: ^16.13.1 || ^17.0.0 @@ -39162,6 +39163,15 @@ __metadata: languageName: node linkType: hard +"react-relative-time@npm:^0.0.9": + version: 0.0.9 + resolution: "react-relative-time@npm:0.0.9" + peerDependencies: + react: ">=0.13.0" + checksum: 9c25887125df0eccfd4fee1edc4c99b19bef262ed5c66ac93269b63f0e1654a7c2758489d0d4b534847decc542dac23555896a7e2a0492af4c0ffbf48f1c62fe + languageName: node + linkType: hard + "react-remove-scroll-bar@npm:^2.3.3": version: 2.3.4 resolution: "react-remove-scroll-bar@npm:2.3.4"