feat: add "Save" action for notifications
Signed-off-by: Marek Libra <marek.libra@gmail.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-notifications-backend': minor
|
||||
'@backstage/plugin-notifications': minor
|
||||
---
|
||||
|
||||
the user can newly mark notifications as "Saved" for their better visibility in the future
|
||||
@@ -224,6 +224,12 @@ export async function createRouter(
|
||||
opts.read = false;
|
||||
// or keep undefined
|
||||
}
|
||||
if (req.query.saved === 'true') {
|
||||
opts.saved = true;
|
||||
} else if (req.query.saved === 'false') {
|
||||
opts.saved = false;
|
||||
// or keep undefined
|
||||
}
|
||||
if (req.query.created_after) {
|
||||
const sinceEpoch = Date.parse(String(req.query.created_after));
|
||||
if (isNaN(sinceEpoch)) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export type GetNotificationsOptions = {
|
||||
limit?: number;
|
||||
search?: string;
|
||||
read?: boolean;
|
||||
saved?: boolean;
|
||||
createdAfter?: Date;
|
||||
sort?: 'created' | 'topic' | 'origin';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
|
||||
@@ -30,6 +30,7 @@ export type GetNotificationsOptions = {
|
||||
limit?: number;
|
||||
search?: string;
|
||||
read?: boolean;
|
||||
saved?: boolean;
|
||||
createdAfter?: Date;
|
||||
sort?: 'created' | 'topic' | 'origin';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
|
||||
@@ -61,6 +61,9 @@ export class NotificationsClient implements NotificationsApi {
|
||||
if (options?.read !== undefined) {
|
||||
queryString.append('read', options.read ? 'true' : 'false');
|
||||
}
|
||||
if (options?.saved !== undefined) {
|
||||
queryString.append('saved', options.saved ? 'true' : 'false');
|
||||
}
|
||||
if (options?.createdAfter !== undefined) {
|
||||
queryString.append('created_after', options.createdAfter.toISOString());
|
||||
}
|
||||
|
||||
+30
-10
@@ -37,6 +37,8 @@ export type NotificationsFiltersProps = {
|
||||
onCreatedAfterChanged: (value: string) => void;
|
||||
sorting: SortBy;
|
||||
onSortingChanged: (sortBy: SortBy) => void;
|
||||
saved?: boolean;
|
||||
onSavedChanged: (checked: boolean | undefined) => void;
|
||||
};
|
||||
|
||||
export const CreatedAfterOptions: {
|
||||
@@ -113,6 +115,8 @@ export const NotificationsFilters = ({
|
||||
onUnreadOnlyChanged,
|
||||
createdAfter,
|
||||
onCreatedAfterChanged,
|
||||
saved,
|
||||
onSavedChanged,
|
||||
}: NotificationsFiltersProps) => {
|
||||
const sortByText = getSortByText(sorting);
|
||||
|
||||
@@ -122,13 +126,23 @@ export const NotificationsFilters = ({
|
||||
onCreatedAfterChanged(event.target.value as string);
|
||||
};
|
||||
|
||||
const handleOnUnreadOnlyChanged = (
|
||||
const handleOnViewChanged = (
|
||||
event: React.ChangeEvent<{ name?: string; value: unknown }>,
|
||||
) => {
|
||||
let value = undefined;
|
||||
if (event.target.value === 'unread') value = true;
|
||||
if (event.target.value === 'read') value = false;
|
||||
onUnreadOnlyChanged(value);
|
||||
if (event.target.value === 'unread') {
|
||||
onUnreadOnlyChanged(true);
|
||||
onSavedChanged(undefined);
|
||||
} else if (event.target.value === 'read') {
|
||||
onUnreadOnlyChanged(false);
|
||||
onSavedChanged(undefined);
|
||||
} else if (event.target.value === 'saved') {
|
||||
onUnreadOnlyChanged(undefined);
|
||||
onSavedChanged(true);
|
||||
} else {
|
||||
// All
|
||||
onUnreadOnlyChanged(undefined);
|
||||
onSavedChanged(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnSortByChanged = (
|
||||
@@ -139,9 +153,14 @@ export const NotificationsFilters = ({
|
||||
onSortingChanged({ ...option.sortBy });
|
||||
};
|
||||
|
||||
let unreadOnlyValue = 'all';
|
||||
if (unreadOnly) unreadOnlyValue = 'unread';
|
||||
if (unreadOnly === false) unreadOnlyValue = 'read';
|
||||
let viewValue = 'all';
|
||||
if (saved) {
|
||||
viewValue = 'saved';
|
||||
} else if (unreadOnly) {
|
||||
viewValue = 'unread';
|
||||
} else if (unreadOnly === false) {
|
||||
viewValue = 'read';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -156,10 +175,11 @@ export const NotificationsFilters = ({
|
||||
<Select
|
||||
labelId="notifications-filter-view"
|
||||
label="View"
|
||||
value={unreadOnlyValue}
|
||||
onChange={handleOnUnreadOnlyChanged}
|
||||
value={viewValue}
|
||||
onChange={handleOnViewChanged}
|
||||
>
|
||||
<MenuItem value="unread">New only</MenuItem>
|
||||
<MenuItem value="saved">Saved</MenuItem>
|
||||
<MenuItem value="read">Marked as read</MenuItem>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
</Select>
|
||||
|
||||
@@ -37,6 +37,7 @@ export const NotificationsPage = () => {
|
||||
const [refresh, setRefresh] = React.useState(false);
|
||||
const { lastSignal } = useSignal('notifications');
|
||||
const [unreadOnly, setUnreadOnly] = React.useState<boolean | undefined>(true);
|
||||
const [saved, setSaved] = React.useState<boolean | undefined>(undefined);
|
||||
const [pageNumber, setPageNumber] = React.useState(0);
|
||||
const [pageSize, setPageSize] = React.useState(5);
|
||||
const [containsText, setContainsText] = React.useState<string>();
|
||||
@@ -56,6 +57,9 @@ export const NotificationsPage = () => {
|
||||
if (unreadOnly !== undefined) {
|
||||
options.read = !unreadOnly;
|
||||
}
|
||||
if (saved !== undefined) {
|
||||
options.saved = saved;
|
||||
}
|
||||
|
||||
const createdAfterDate = CreatedAfterOptions[createdAfter].getDate();
|
||||
if (createdAfterDate.valueOf() > 0) {
|
||||
@@ -64,7 +68,15 @@ export const NotificationsPage = () => {
|
||||
|
||||
return api.getNotifications(options);
|
||||
},
|
||||
[containsText, unreadOnly, createdAfter, pageNumber, pageSize, sorting],
|
||||
[
|
||||
containsText,
|
||||
unreadOnly,
|
||||
createdAfter,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sorting,
|
||||
saved,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,6 +112,8 @@ export const NotificationsPage = () => {
|
||||
onCreatedAfterChanged={setCreatedAfter}
|
||||
onSortingChanged={setSorting}
|
||||
sorting={sorting}
|
||||
saved={saved}
|
||||
onSavedChanged={setSaved}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
|
||||
@@ -17,13 +17,11 @@ import React, { useMemo } from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
// @ts-ignore
|
||||
import RelativeTime from 'react-relative-time';
|
||||
import { Box, IconButton, Tooltip, Typography } from '@material-ui/core';
|
||||
import { Box, Grid, IconButton, Tooltip, Typography } from '@material-ui/core';
|
||||
import { Notification } from '@backstage/plugin-notifications-common';
|
||||
|
||||
import { notificationsApiRef } from '../../api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import MarkAsUnreadIcon from '@material-ui/icons/Markunread';
|
||||
import MarkAsReadIcon from '@material-ui/icons/CheckCircle';
|
||||
import {
|
||||
Link,
|
||||
Table,
|
||||
@@ -31,6 +29,11 @@ import {
|
||||
TableColumn,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
import MarkAsUnreadIcon from '@material-ui/icons/Markunread' /* TODO: use Drafts and MarkAsUnread once we have mui 5 icons */;
|
||||
import MarkAsReadIcon from '@material-ui/icons/CheckCircle';
|
||||
import MarkAsUnsavedIcon from '@material-ui/icons/LabelOff' /* TODO: use BookmarkRemove and BookmarkAdd once we have mui 5 icons */;
|
||||
import MarkAsSavedIcon from '@material-ui/icons/Label';
|
||||
|
||||
const ThrottleDelayMs = 1000;
|
||||
|
||||
/** @public */
|
||||
@@ -71,6 +74,18 @@ export const NotificationsTable = ({
|
||||
[notificationsApi, onUpdate],
|
||||
);
|
||||
|
||||
const onSwitchSavedStatus = React.useCallback(
|
||||
(notification: Notification) => {
|
||||
notificationsApi
|
||||
.updateNotifications({
|
||||
ids: [notification.id],
|
||||
saved: !notification.saved,
|
||||
})
|
||||
.then(() => onUpdate());
|
||||
},
|
||||
[notificationsApi, onUpdate],
|
||||
);
|
||||
|
||||
const throttledContainsTextHandler = useMemo(
|
||||
() => throttle(setContainsText, ThrottleDelayMs),
|
||||
[setContainsText],
|
||||
@@ -136,7 +151,6 @@ export const NotificationsTable = ({
|
||||
// },
|
||||
// },
|
||||
{
|
||||
// TODO: action for saving notifications
|
||||
// actions
|
||||
width: '1rem',
|
||||
render: (notification: Notification) => {
|
||||
@@ -147,24 +161,47 @@ export const NotificationsTable = ({
|
||||
? MarkAsUnreadIcon
|
||||
: MarkAsReadIcon;
|
||||
|
||||
const markAsSavedText = !!notification.saved
|
||||
? 'Undo save'
|
||||
: 'Save for later';
|
||||
|
||||
const SavedIconComponent = !!notification.saved
|
||||
? MarkAsUnsavedIcon
|
||||
: MarkAsSavedIcon;
|
||||
|
||||
return (
|
||||
<Tooltip title={markAsReadText}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
onSwitchReadStatus(notification);
|
||||
}}
|
||||
>
|
||||
<IconComponent aria-label={markAsReadText} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Grid container wrap="nowrap">
|
||||
<Grid item>
|
||||
<Tooltip title={markAsSavedText}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
onSwitchSavedStatus(notification);
|
||||
}}
|
||||
>
|
||||
<SavedIconComponent aria-label={markAsSavedText} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid item>
|
||||
<Tooltip title={markAsReadText}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
onSwitchReadStatus(notification);
|
||||
}}
|
||||
>
|
||||
<IconComponent aria-label={markAsReadText} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[onSwitchReadStatus],
|
||||
[onSwitchReadStatus, onSwitchSavedStatus],
|
||||
);
|
||||
|
||||
// TODO: render "Saved notifications" as "Pinned"
|
||||
return (
|
||||
<Table<Notification>
|
||||
isLoading={isLoading}
|
||||
|
||||
Reference in New Issue
Block a user