@stack-spot/portal-components 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/components/AnimatedHeight.d.ts +59 -0
- package/dist/components/AnimatedHeight.d.ts.map +1 -0
- package/dist/components/AnimatedHeight.js +105 -0
- package/dist/components/AnimatedHeight.js.map +1 -0
- package/dist/components/Placeholder.d.ts +6 -4
- package/dist/components/Placeholder.d.ts.map +1 -1
- package/dist/components/Placeholder.js +5 -4
- package/dist/components/Placeholder.js.map +1 -1
- package/dist/components/TimelineSection.d.ts +25 -0
- package/dist/components/TimelineSection.d.ts.map +1 -0
- package/dist/components/TimelineSection.js +27 -0
- package/dist/components/TimelineSection.js.map +1 -0
- package/dist/components/error/ErrorFeedback.d.ts +9 -1
- package/dist/components/error/ErrorFeedback.d.ts.map +1 -1
- package/dist/components/error/ErrorFeedback.js +41 -4
- package/dist/components/error/ErrorFeedback.js.map +1 -1
- package/dist/components/form/SearchInput.d.ts +9 -0
- package/dist/components/form/SearchInput.d.ts.map +1 -0
- package/dist/components/form/SearchInput.js +28 -0
- package/dist/components/form/SearchInput.js.map +1 -0
- package/dist/components/form/Select.d.ts +69 -0
- package/dist/components/form/Select.d.ts.map +1 -0
- package/dist/components/form/Select.js +161 -0
- package/dist/components/form/Select.js.map +1 -0
- package/dist/components/{Notifications → notification}/NotificationComponent.d.ts +2 -1
- package/dist/components/notification/NotificationComponent.d.ts.map +1 -0
- package/dist/components/{Notifications → notification}/NotificationComponent.js +12 -4
- package/dist/components/notification/NotificationComponent.js.map +1 -0
- package/dist/components/notification/NotificationItem.d.ts +42 -0
- package/dist/components/notification/NotificationItem.d.ts.map +1 -0
- package/dist/components/{Notifications → notification}/NotificationItem.js +27 -12
- package/dist/components/notification/NotificationItem.js.map +1 -0
- package/dist/components/notification/NotificationList.d.ts +39 -0
- package/dist/components/notification/NotificationList.d.ts.map +1 -0
- package/dist/components/notification/NotificationList.js +82 -0
- package/dist/components/notification/NotificationList.js.map +1 -0
- package/dist/components/notification/NotificationPlaceholder.d.ts +12 -0
- package/dist/components/notification/NotificationPlaceholder.d.ts.map +1 -0
- package/dist/components/notification/NotificationPlaceholder.js +22 -0
- package/dist/components/notification/NotificationPlaceholder.js.map +1 -0
- package/dist/components/{Notifications → notification}/types.d.ts +16 -0
- package/dist/components/notification/types.d.ts.map +1 -0
- package/dist/components/{Notifications → notification}/types.js +3 -0
- package/dist/components/notification/types.js.map +1 -0
- package/dist/containers/NotificationsPage.d.ts +2 -0
- package/dist/containers/NotificationsPage.d.ts.map +1 -0
- package/dist/containers/NotificationsPage.js +58 -0
- package/dist/containers/NotificationsPage.js.map +1 -0
- package/dist/context/notification/LazyNotificationList.d.ts +28 -0
- package/dist/context/notification/LazyNotificationList.d.ts.map +1 -0
- package/dist/context/notification/LazyNotificationList.js +128 -0
- package/dist/context/notification/LazyNotificationList.js.map +1 -0
- package/dist/context/notification/NotificationController.d.ts +24 -0
- package/dist/context/notification/NotificationController.d.ts.map +1 -0
- package/dist/context/notification/NotificationController.js +136 -0
- package/dist/context/notification/NotificationController.js.map +1 -0
- package/dist/context/notification/context.d.ts +9 -0
- package/dist/context/notification/context.d.ts.map +1 -0
- package/dist/context/notification/context.js +12 -0
- package/dist/context/notification/context.js.map +1 -0
- package/dist/context/notification/hooks.d.ts +13 -0
- package/dist/context/notification/hooks.d.ts.map +1 -0
- package/dist/context/notification/hooks.js +77 -0
- package/dist/context/notification/hooks.js.map +1 -0
- package/dist/context/notification/types.d.ts +57 -0
- package/dist/context/notification/types.d.ts.map +1 -0
- package/dist/context/notification/types.js +2 -0
- package/dist/context/notification/types.js.map +1 -0
- package/dist/hooks/manual-render.d.ts +8 -0
- package/dist/hooks/manual-render.d.ts.map +1 -0
- package/dist/hooks/manual-render.js +10 -0
- package/dist/hooks/manual-render.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/notifications.d.ts +11 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +10 -0
- package/dist/notifications.js.map +1 -0
- package/dist/svg/GenericPlaceholder.d.ts +5 -0
- package/dist/svg/GenericPlaceholder.d.ts.map +1 -0
- package/dist/svg/GenericPlaceholder.js +4 -0
- package/dist/svg/GenericPlaceholder.js.map +1 -0
- package/dist/svg/index.d.ts +1 -0
- package/dist/svg/index.d.ts.map +1 -1
- package/dist/svg/index.js +1 -0
- package/dist/svg/index.js.map +1 -1
- package/dist/utils/promise.d.ts +2 -0
- package/dist/utils/promise.d.ts.map +1 -0
- package/dist/utils/promise.js +6 -0
- package/dist/utils/promise.js.map +1 -0
- package/package.json +8 -4
- package/src/components/AnimatedHeight.tsx +174 -0
- package/src/components/Placeholder.tsx +13 -8
- package/src/components/TimelineSection.tsx +54 -0
- package/src/components/error/ErrorFeedback.tsx +93 -55
- package/src/components/form/SearchInput.tsx +69 -0
- package/src/components/form/Select.tsx +264 -0
- package/src/components/{Notifications → notification}/NotificationComponent.tsx +13 -5
- package/src/components/{Notifications → notification}/NotificationItem.tsx +76 -34
- package/src/components/notification/NotificationList.tsx +167 -0
- package/src/components/notification/NotificationPlaceholder.tsx +40 -0
- package/src/components/{Notifications → notification}/types.ts +21 -0
- package/src/containers/NotificationsPage.tsx +98 -0
- package/src/context/notification/LazyNotificationList.ts +95 -0
- package/src/context/notification/NotificationController.ts +104 -0
- package/src/context/notification/context.tsx +23 -0
- package/src/context/notification/hooks.ts +82 -0
- package/src/context/notification/types.ts +64 -0
- package/src/hooks/manual-render.tsx +10 -0
- package/src/index.ts +2 -1
- package/src/notifications.ts +11 -0
- package/src/svg/GenericPlaceholder.tsx +19 -0
- package/src/svg/index.ts +1 -0
- package/src/utils/promise.ts +5 -0
- package/dist/components/Notifications/NotificationComponent.d.ts.map +0 -1
- package/dist/components/Notifications/NotificationComponent.js.map +0 -1
- package/dist/components/Notifications/NotificationItem.d.ts +0 -17
- package/dist/components/Notifications/NotificationItem.d.ts.map +0 -1
- package/dist/components/Notifications/NotificationItem.js.map +0 -1
- package/dist/components/Notifications/index.d.ts +0 -4
- package/dist/components/Notifications/index.d.ts.map +0 -1
- package/dist/components/Notifications/index.js +0 -4
- package/dist/components/Notifications/index.js.map +0 -1
- package/dist/components/Notifications/types.d.ts.map +0 -1
- package/dist/components/Notifications/types.js.map +0 -1
- package/src/components/Notifications/index.tsx +0 -3
|
@@ -24,6 +24,7 @@ const statusToColor: Record<string, OneOfColorSchemesWithVariants> = {
|
|
|
24
24
|
interface Props {
|
|
25
25
|
notification: StackspotNotification,
|
|
26
26
|
isSummary: boolean,
|
|
27
|
+
// @deprecated this property currently does nothing. Remove in next major.
|
|
27
28
|
id?: string,
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -38,12 +39,12 @@ const style: Styles = {
|
|
|
38
39
|
/**
|
|
39
40
|
* NotificationHeader component that renders the header of a notification.
|
|
40
41
|
*
|
|
41
|
-
* @param props the component's props
|
|
42
|
+
* @param props the component's props.
|
|
42
43
|
*/
|
|
43
|
-
const NotificationHeader = ({
|
|
44
|
+
const NotificationHeader = ({ title, isSummary }: { title: string, isSummary: boolean }) => (
|
|
44
45
|
<Flex justifyContent="space-between" mb={2} sx={{ maxWidth: isSummary ? '330px' : '100%' }} flexWrap="nowrap">
|
|
45
46
|
<Text appearance={isSummary ? 'body2' : 'body1'} nowrapEllipsis>
|
|
46
|
-
{
|
|
47
|
+
{title}
|
|
47
48
|
</Text>
|
|
48
49
|
</Flex>
|
|
49
50
|
)
|
|
@@ -99,9 +100,15 @@ const NotificationContent = ({ notification, isSummary }: Props) => {
|
|
|
99
100
|
)
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
interface NotificationFooterProps {
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
interface NotificationFooterProps {
|
|
104
|
+
/**
|
|
105
|
+
* The URL to open when the action button is clicked.
|
|
106
|
+
*/
|
|
107
|
+
actionURL: string,
|
|
108
|
+
/**
|
|
109
|
+
* Function to call when the user clicks the action button.
|
|
110
|
+
*/
|
|
111
|
+
onClickAction: () => void,
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
/**
|
|
@@ -109,7 +116,7 @@ interface NotificationFooterProps {
|
|
|
109
116
|
*
|
|
110
117
|
* @param props the component's props {@link NotificationFooterProps}.
|
|
111
118
|
*/
|
|
112
|
-
const NotificationFooter = ({
|
|
119
|
+
const NotificationFooter = ({ actionURL, onClickAction }: NotificationFooterProps) => {
|
|
113
120
|
const t = useTranslate(dictionary)
|
|
114
121
|
const Link = useAnchorTag()
|
|
115
122
|
|
|
@@ -118,9 +125,9 @@ const NotificationFooter = ({ call_to_action, onClickMarkReadUnread }: Notificat
|
|
|
118
125
|
<Button
|
|
119
126
|
size="sm"
|
|
120
127
|
colorScheme="inverse"
|
|
121
|
-
onClick={
|
|
128
|
+
onClick={onClickAction}
|
|
122
129
|
as={Link}
|
|
123
|
-
href={
|
|
130
|
+
href={actionURL}
|
|
124
131
|
aria-label={t.takeMeThere}
|
|
125
132
|
>
|
|
126
133
|
<Text colorScheme="inverse.contrastText">
|
|
@@ -131,41 +138,76 @@ const NotificationFooter = ({ call_to_action, onClickMarkReadUnread }: Notificat
|
|
|
131
138
|
)
|
|
132
139
|
}
|
|
133
140
|
|
|
134
|
-
interface
|
|
135
|
-
|
|
141
|
+
interface DeprecatedNotificationItemProps extends Props {
|
|
142
|
+
/**
|
|
143
|
+
* @deprecated use `onCommit` and `onClickAction` instead.
|
|
144
|
+
*
|
|
145
|
+
* Function to call when the user marks a notification as read or unread. This can happen either through the mail icon or when the user
|
|
146
|
+
* clicks the action button of a notification. Type will be "icon" on the first case or "callToAction" on the former.
|
|
147
|
+
*
|
|
148
|
+
* Tip: clicking the action button (callToAction) should only change the read status if the message is unread. This is not treated by this
|
|
149
|
+
* component, be sure to handle it on your side.
|
|
150
|
+
*
|
|
151
|
+
* Deprecation warning: since the deprecation, the parameter `read` is always true.
|
|
152
|
+
*/
|
|
153
|
+
onClickMarkReadUnread: (read: boolean, type: 'callToAction' | 'icon') => void | Promise<void>,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface NewNotificationItemProps extends Props {
|
|
157
|
+
/**
|
|
158
|
+
* Function to call when the user marks a notification as read (committed).
|
|
159
|
+
*/
|
|
160
|
+
onCommit: () => void,
|
|
161
|
+
/**
|
|
162
|
+
* Whenever the user clicks the action button of a notification, the notification is marked as read (committed) and the browser is
|
|
163
|
+
* redirected to the page that corresponds to the action.
|
|
164
|
+
*
|
|
165
|
+
* If you need additional behavior, use this parameter, which is a function to call whenever the button is clicked.
|
|
166
|
+
*/
|
|
167
|
+
onClickAction?: () => void,
|
|
136
168
|
}
|
|
137
169
|
|
|
170
|
+
type NotificationItemProps = DeprecatedNotificationItemProps | NewNotificationItemProps
|
|
171
|
+
|
|
138
172
|
/**
|
|
139
173
|
* NotificationItem component that renders a notification item.
|
|
140
174
|
*
|
|
141
175
|
* @param props the component's props {@link NotificationItemProps}.
|
|
142
176
|
*/
|
|
143
|
-
export const NotificationItem = ({ notification, isSummary,
|
|
177
|
+
export const NotificationItem = ({ notification, isSummary, ...props }: NotificationItemProps) => {
|
|
144
178
|
const t = useTranslate(dictionary)
|
|
179
|
+
|
|
180
|
+
function commit() {
|
|
181
|
+
if ('onCommit' in props) props.onCommit()
|
|
182
|
+
else props.onClickMarkReadUnread(true, 'icon')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function clickAction() {
|
|
186
|
+
if ('onCommit' in props) {
|
|
187
|
+
if (!notification.committed) props.onCommit()
|
|
188
|
+
props.onClickAction?.()
|
|
189
|
+
}
|
|
190
|
+
else props.onClickMarkReadUnread(true, 'callToAction')
|
|
191
|
+
}
|
|
192
|
+
|
|
145
193
|
return (
|
|
146
|
-
<Box sx={{ position: 'relative' }}
|
|
194
|
+
<Box sx={{ position: 'relative' }}>
|
|
147
195
|
<Flex bg="light.400" p="3 3 3 5" r="xs"
|
|
148
196
|
flexDirection="column" w="100%" sx={styles.item(statusToColor[notification.criticality], notification.committed)}>
|
|
149
|
-
<NotificationHeader
|
|
197
|
+
<NotificationHeader title={notification.title} isSummary={isSummary} />
|
|
150
198
|
<NotificationContent notification={notification} isSummary={isSummary} />
|
|
151
|
-
{notification.call_to_action && <NotificationFooter
|
|
152
|
-
onClickMarkReadUnread={(type) => onClickMarkReadUnread(notification.committed, type)} />}
|
|
199
|
+
{notification.call_to_action && <NotificationFooter actionURL={notification.call_to_action} onClickAction={clickAction} />}
|
|
153
200
|
</Flex>
|
|
154
201
|
<Box sx={{ position: 'absolute', top: '8px', right: '8px' }}>
|
|
155
|
-
<Tooltip text={notification.committed ? t.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}}>
|
|
165
|
-
<IconBox size="xs">
|
|
166
|
-
{notification.committed ? <Envelope /> : <EnvelopeOpen />}
|
|
167
|
-
</IconBox>
|
|
168
|
-
</IconButton>
|
|
202
|
+
<Tooltip text={notification.committed ? t.committed : t.uncommitted} position="left" sx={style.tooltip}>
|
|
203
|
+
{notification.committed
|
|
204
|
+
? <IconBox role="img" aria-label={t.committed} size="xs" style={{ margin: '4px', opacity: 0.5 }}><EnvelopeOpen /></IconBox>
|
|
205
|
+
: (
|
|
206
|
+
<IconButton aria-label={t.uncommitted} onClick={commit}>
|
|
207
|
+
<IconBox size="xs"><Envelope /></IconBox>
|
|
208
|
+
</IconButton>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
169
211
|
</Tooltip>
|
|
170
212
|
</Box>
|
|
171
213
|
</Box>
|
|
@@ -180,8 +222,8 @@ const dictionary = {
|
|
|
180
222
|
STUDIO: 'Studio',
|
|
181
223
|
daysAgo: 'days ago',
|
|
182
224
|
today: 'today',
|
|
183
|
-
|
|
184
|
-
|
|
225
|
+
committed: 'This notification has been read.',
|
|
226
|
+
uncommitted: 'This notification has not been read yet. Click to mark as read.',
|
|
185
227
|
},
|
|
186
228
|
pt: {
|
|
187
229
|
takeMeThere: 'Leve-me para lá',
|
|
@@ -190,7 +232,7 @@ const dictionary = {
|
|
|
190
232
|
STUDIO: 'Estúdio',
|
|
191
233
|
daysAgo: 'dias atrás',
|
|
192
234
|
today: 'hoje',
|
|
193
|
-
|
|
194
|
-
|
|
235
|
+
committed: 'Esta notificação já foi lida.',
|
|
236
|
+
uncommitted: 'Esta notificação ainda não foi lida. Clique para marcar como lida.',
|
|
195
237
|
},
|
|
196
238
|
} satisfies Dictionary
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Box, Flex } from '@citric/core'
|
|
2
|
+
import { listToClass } from '@stack-spot/portal-theme'
|
|
3
|
+
import { Month } from '@stack-spot/portal-translate'
|
|
4
|
+
import { last } from 'lodash'
|
|
5
|
+
import { useMemo } from 'react'
|
|
6
|
+
import { styled } from 'styled-components'
|
|
7
|
+
import { InfiniteScroll } from '../InfiniteScroll'
|
|
8
|
+
import { TimelineSection } from '../TimelineSection'
|
|
9
|
+
import { NotificationItem } from './NotificationItem'
|
|
10
|
+
import { NotificationPlaceholder } from './NotificationPlaceholder'
|
|
11
|
+
import { InfiniteScrollConfig, StackspotNotification } from './types'
|
|
12
|
+
|
|
13
|
+
export interface NotificationListProps {
|
|
14
|
+
/**
|
|
15
|
+
* Function to call when the message is marked as read (committed).
|
|
16
|
+
* @param id the id of the notification where the read status changed.
|
|
17
|
+
*/
|
|
18
|
+
onCommit: (id: string) => void,
|
|
19
|
+
/**
|
|
20
|
+
* Optional. Function called when the button to perform the notification action is clicked. This function will be run in addition to
|
|
21
|
+
* the notification action.
|
|
22
|
+
* @param id the id of the notification where the button was clicked
|
|
23
|
+
*/
|
|
24
|
+
onClickAction?: (id: string) => void,
|
|
25
|
+
/**
|
|
26
|
+
* If you need this notification list to be have an infinite scroll behavior, set this option.
|
|
27
|
+
*/
|
|
28
|
+
infiniteScroll?: InfiniteScrollConfig,
|
|
29
|
+
/**
|
|
30
|
+
* The notifications themselves.
|
|
31
|
+
*/
|
|
32
|
+
items: StackspotNotification[],
|
|
33
|
+
/**
|
|
34
|
+
* A compact notification list don't show date headers (as a timeline) or descriptions of notifications.
|
|
35
|
+
*/
|
|
36
|
+
compact?: boolean,
|
|
37
|
+
/**
|
|
38
|
+
* Whether or not the content is loading. If this is true, the content becomes transparent and the cursor turns into the progress cursor.
|
|
39
|
+
*/
|
|
40
|
+
loading?: boolean,
|
|
41
|
+
/**
|
|
42
|
+
* If true, when the list is empty, the placeholder will say "nothing found" instead of "no notifications".
|
|
43
|
+
*/
|
|
44
|
+
showEmptySearch?: boolean,
|
|
45
|
+
style?: React.CSSProperties,
|
|
46
|
+
className?: string,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface NotificationGroup {
|
|
50
|
+
month: Month,
|
|
51
|
+
day: number,
|
|
52
|
+
year: number,
|
|
53
|
+
items: StackspotNotification[],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const StyledBox = styled(Box)`
|
|
57
|
+
width: 100%;
|
|
58
|
+
position: relative;
|
|
59
|
+
transition: opacity 0.3s;
|
|
60
|
+
|
|
61
|
+
> div:first-child{
|
|
62
|
+
width: 100%;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
&.loading {
|
|
66
|
+
opacity: 0.6;
|
|
67
|
+
.loading-mask {
|
|
68
|
+
pointer-events: auto;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.loading-mask {
|
|
73
|
+
opacity: 0;
|
|
74
|
+
position: absolute;
|
|
75
|
+
pointer-events: none;
|
|
76
|
+
top: 0;
|
|
77
|
+
right: 0;
|
|
78
|
+
left: 0;
|
|
79
|
+
bottom: 0;
|
|
80
|
+
cursor: progress;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.placeholder.compact {
|
|
84
|
+
& > div {
|
|
85
|
+
padding-block: 0;
|
|
86
|
+
gap: 15px;
|
|
87
|
+
& > div {
|
|
88
|
+
text-align: center;
|
|
89
|
+
h4 {
|
|
90
|
+
margin-bottom: 10px;
|
|
91
|
+
}
|
|
92
|
+
p {
|
|
93
|
+
margin: 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
`
|
|
99
|
+
|
|
100
|
+
// this assumes the data from the backend is ordered by date (trigger_at)
|
|
101
|
+
function groupNotificationsByDate(notifications: StackspotNotification[]): NotificationGroup[] {
|
|
102
|
+
const groups: NotificationGroup[] = []
|
|
103
|
+
for (const n of notifications) {
|
|
104
|
+
let currentGroup = last(groups)
|
|
105
|
+
const date = new Date(n.trigger_at)
|
|
106
|
+
const year = date.getFullYear()
|
|
107
|
+
const month = date.getMonth() as Month
|
|
108
|
+
const day = date.getDate()
|
|
109
|
+
if (!currentGroup || currentGroup.day !== day || currentGroup.month !== month || currentGroup.year !== year) {
|
|
110
|
+
currentGroup = { year, month, day, items: [] }
|
|
111
|
+
groups.push(currentGroup)
|
|
112
|
+
}
|
|
113
|
+
currentGroup.items.push(n)
|
|
114
|
+
}
|
|
115
|
+
return groups
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const NotificationList = (
|
|
119
|
+
{ items, compact = false, onCommit, onClickAction, infiniteScroll, loading, showEmptySearch, style, className }: NotificationListProps,
|
|
120
|
+
) => {
|
|
121
|
+
const groups: NotificationGroup[] = useMemo(
|
|
122
|
+
() => compact ? [{ day: 0, month: 0, year: 0, items }] : groupNotificationsByDate(items),
|
|
123
|
+
[compact, items],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const renderNotifications = (notifications: StackspotNotification[], key?: string) => (
|
|
127
|
+
<Flex sx={{ gap: '4px' }} flexDirection="column" key={key}>
|
|
128
|
+
{notifications?.map((item) => (
|
|
129
|
+
<NotificationItem
|
|
130
|
+
key={item.id}
|
|
131
|
+
notification={item}
|
|
132
|
+
isSummary={compact}
|
|
133
|
+
onCommit={() => onCommit(item.id)}
|
|
134
|
+
onClickAction={() => onClickAction?.(item.id)}
|
|
135
|
+
/>
|
|
136
|
+
))}
|
|
137
|
+
</Flex>
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return infiniteScroll?.scrollableTarget !== null && (
|
|
141
|
+
<StyledBox style={style} className={listToClass([className, loading && 'loading'])}>
|
|
142
|
+
{items.length ? (
|
|
143
|
+
<InfiniteScroll
|
|
144
|
+
dataLength={items.length || 0}
|
|
145
|
+
next={infiniteScroll?.loadMore ?? (() => {})}
|
|
146
|
+
hasMore={infiniteScroll?.hasMore ?? false}
|
|
147
|
+
// @ts-ignore: the library is wrongly typed and abandoned, meaning, it will never be fixed. The source code clearly accepts
|
|
148
|
+
// HTMLElements as scrollable targets:
|
|
149
|
+
// https://github.com/ankeetmaini/react-infinite-scroll-component/blob/master/src/index.tsx#L168
|
|
150
|
+
scrollableTarget={infiniteScroll?.scrollableTarget}
|
|
151
|
+
>
|
|
152
|
+
<Flex sx={{ gap: '24px' }} flexDirection="column">
|
|
153
|
+
{groups.map((group) => compact
|
|
154
|
+
? renderNotifications(group.items, 'compact')
|
|
155
|
+
: (
|
|
156
|
+
<TimelineSection key={`${group.day}-${group.month}-${group.year}`} month={group.month} day={group.day}>
|
|
157
|
+
{renderNotifications(group.items)}
|
|
158
|
+
</TimelineSection>
|
|
159
|
+
),
|
|
160
|
+
)}
|
|
161
|
+
</Flex>
|
|
162
|
+
</InfiniteScroll>
|
|
163
|
+
) : <NotificationPlaceholder isSearch={showEmptySearch} className={listToClass(['placeholder', compact && 'compact'])} />}
|
|
164
|
+
<div className="loading-mask"></div>
|
|
165
|
+
</StyledBox>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { SxProperties } from '@citric/core/dist/sx'
|
|
2
|
+
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
3
|
+
import { PlaceholderCallToAction } from '../Placeholder'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
isSearch?: boolean,
|
|
7
|
+
style?: React.CSSProperties,
|
|
8
|
+
className?: string,
|
|
9
|
+
sx?: SxProperties,
|
|
10
|
+
sxCard?: SxProperties,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const NotificationPlaceholder = ({ isSearch, sx, sxCard, className, style }: Props) => {
|
|
14
|
+
const t = useTranslate(dictionary)
|
|
15
|
+
|
|
16
|
+
return <PlaceholderCallToAction
|
|
17
|
+
title={isSearch ? t.placeholderSearchTitle : t.placeholderTitle}
|
|
18
|
+
description={isSearch ? t.placeholderSearchDescription : t.placeholderDescription}
|
|
19
|
+
fullWidth
|
|
20
|
+
sx={sx}
|
|
21
|
+
sxCard={sxCard}
|
|
22
|
+
className={className}
|
|
23
|
+
style={style}
|
|
24
|
+
/>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const dictionary = {
|
|
28
|
+
en: {
|
|
29
|
+
placeholderTitle: 'No notifications',
|
|
30
|
+
placeholderDescription: 'Your notifications will appear here.',
|
|
31
|
+
placeholderSearchTitle: 'No notifications found',
|
|
32
|
+
placeholderSearchDescription: 'You can change the current filters in the fields above',
|
|
33
|
+
},
|
|
34
|
+
pt: {
|
|
35
|
+
placeholderTitle: 'Nenhuma notificação',
|
|
36
|
+
placeholderDescription: 'Suas notificações aparecerão aqui.',
|
|
37
|
+
placeholderSearchTitle: 'Nenhuma notificação encontrada',
|
|
38
|
+
placeholderSearchDescription: 'Você pode mudar os filtros aplicados nos campos acima',
|
|
39
|
+
},
|
|
40
|
+
} satisfies Dictionary
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// fixme: please, do not ignore lint rules. They exist for a reason. Next major: transform this enum into a disjoint string type.
|
|
1
2
|
// eslint-disable-next-line no-restricted-syntax
|
|
2
3
|
export enum NotificationType {
|
|
3
4
|
High = 'HIGH',
|
|
@@ -5,6 +6,8 @@ export enum NotificationType {
|
|
|
5
6
|
Low = 'LOW',
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
|
|
10
|
+
// fixme: please, do not ignore lint rules. They exist for a reason. Next major: transform this enum into a disjoint string type.
|
|
8
11
|
// eslint-disable-next-line no-restricted-syntax
|
|
9
12
|
export enum NotificationContext {
|
|
10
13
|
Account = 'ACCOUNT',
|
|
@@ -40,7 +43,25 @@ export interface NotificationCommitted {
|
|
|
40
43
|
committed: boolean,
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
// fixme: please, do not ignore lint rules. They exist for a reason. Next major: transform this enum into a disjoint string type.
|
|
43
47
|
// eslint-disable-next-line no-restricted-syntax
|
|
44
48
|
export enum UnreadType {
|
|
45
49
|
Unread = 'unread',
|
|
46
50
|
}
|
|
51
|
+
|
|
52
|
+
export interface InfiniteScrollConfig {
|
|
53
|
+
/**
|
|
54
|
+
* Function to load more items into the list. Called when the scroll is almost reaching its end.
|
|
55
|
+
*/
|
|
56
|
+
loadMore: () => void,
|
|
57
|
+
/**
|
|
58
|
+
* Set this to false to prevent the scroll from loading more items when it reaches the end.
|
|
59
|
+
*/
|
|
60
|
+
hasMore: boolean,
|
|
61
|
+
/**
|
|
62
|
+
* Defines which scroll will be used as the target of the infinite scroll.
|
|
63
|
+
*
|
|
64
|
+
* If null, nothing renders, it waits until it has a value.
|
|
65
|
+
*/
|
|
66
|
+
scrollableTarget?: string | HTMLElement | null,
|
|
67
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Text } from '@citric/core'
|
|
2
|
+
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
3
|
+
import { styled } from 'styled-components'
|
|
4
|
+
import { AsyncContent } from '../components/AsyncContent'
|
|
5
|
+
import { ErrorFeedback } from '../components/error'
|
|
6
|
+
import { SearchInput } from '../components/form/SearchInput'
|
|
7
|
+
import { Select } from '../components/form/Select'
|
|
8
|
+
import { NotificationList } from '../components/notification/NotificationList'
|
|
9
|
+
import { useNotificationList } from '../context/notification/hooks'
|
|
10
|
+
import { NotificationContext, NotificationPriority } from '../context/notification/types'
|
|
11
|
+
import { useNotificationController } from '../notifications'
|
|
12
|
+
|
|
13
|
+
const FilterBox = styled.div`
|
|
14
|
+
margin: 24px 0;
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: row;
|
|
17
|
+
gap: 8px;
|
|
18
|
+
|
|
19
|
+
> * {
|
|
20
|
+
flex: 1;
|
|
21
|
+
}
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const criticalities: NotificationPriority[] = ['HIGH', 'MEDIUM', 'LOW']
|
|
25
|
+
const contexts: NotificationContext[] = ['ACCOUNT', 'STUDIO', 'WORKSPACE']
|
|
26
|
+
|
|
27
|
+
export const NotificationsPage = () => {
|
|
28
|
+
const t = useTranslate(dictionary)
|
|
29
|
+
const controller = useNotificationController()
|
|
30
|
+
const { hasMore, items, loadMore, applyFilters, filters, status, error } = useNotificationList()
|
|
31
|
+
|
|
32
|
+
return (<>
|
|
33
|
+
<header><Text appearance="h2" as="h1">{t.title}</Text></header>
|
|
34
|
+
<Text appearance="body2" colorScheme="light.700">
|
|
35
|
+
{t.description}
|
|
36
|
+
</Text>
|
|
37
|
+
<FilterBox>
|
|
38
|
+
<SearchInput searchText={t.filter} defaultValue={filters.search} onChange={search => applyFilters({ search })} />
|
|
39
|
+
<Select
|
|
40
|
+
value={filters.criticality}
|
|
41
|
+
options={criticalities}
|
|
42
|
+
onChange={criticality => applyFilters({ criticality })}
|
|
43
|
+
emptyOption={t.allCriticalities}
|
|
44
|
+
renderLabel={o => t[`criticality.${o}`]}
|
|
45
|
+
/>
|
|
46
|
+
<Select
|
|
47
|
+
value={filters.context}
|
|
48
|
+
options={contexts}
|
|
49
|
+
onChange={context => applyFilters({ context })}
|
|
50
|
+
emptyOption={t.allContexts}
|
|
51
|
+
renderLabel={o => t[`context.${o}`]}
|
|
52
|
+
/>
|
|
53
|
+
</FilterBox>
|
|
54
|
+
<AsyncContent
|
|
55
|
+
error={error}
|
|
56
|
+
loading={status === 'startup'}
|
|
57
|
+
errorDetails={{ errorComponent: () => <ErrorFeedback code={error.code} />, reportError: () => {} }}
|
|
58
|
+
>
|
|
59
|
+
<NotificationList
|
|
60
|
+
items={items}
|
|
61
|
+
loading={status === 'loading'}
|
|
62
|
+
onCommit={id => controller.markAsCommitted(id)}
|
|
63
|
+
infiniteScroll={{ hasMore, loadMore }}
|
|
64
|
+
onClickAction={controller.config.onClickAction}
|
|
65
|
+
showEmptySearch
|
|
66
|
+
/>
|
|
67
|
+
</AsyncContent>
|
|
68
|
+
</>)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const dictionary = {
|
|
72
|
+
en: {
|
|
73
|
+
title: 'Notifications',
|
|
74
|
+
description: 'All notifications you received is shown up here.',
|
|
75
|
+
filter: 'Filter',
|
|
76
|
+
allCriticalities: 'All Criticalities',
|
|
77
|
+
'criticality.HIGH': 'High',
|
|
78
|
+
'criticality.MEDIUM': 'Medium',
|
|
79
|
+
'criticality.LOW': 'Low',
|
|
80
|
+
allContexts: 'All contexts',
|
|
81
|
+
'context.ACCOUNT': 'Account',
|
|
82
|
+
'context.STUDIO': 'Studio',
|
|
83
|
+
'context.WORKSPACE': 'Workspace',
|
|
84
|
+
},
|
|
85
|
+
pt: {
|
|
86
|
+
title: 'Notifications',
|
|
87
|
+
description: 'Todas as notificações que você recebeu são mostradas aqui.',
|
|
88
|
+
filter: 'Filtrar',
|
|
89
|
+
allCriticalities: 'Todas as criticidades',
|
|
90
|
+
'criticality.HIGH': 'Alto',
|
|
91
|
+
'criticality.MEDIUM': 'Médio',
|
|
92
|
+
'criticality.LOW': 'Baixo',
|
|
93
|
+
allContexts: 'Todos os contextos',
|
|
94
|
+
'context.ACCOUNT': 'Conta',
|
|
95
|
+
'context.STUDIO': 'Estúdio',
|
|
96
|
+
'context.WORKSPACE': 'Workspace',
|
|
97
|
+
},
|
|
98
|
+
} satisfies Dictionary
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { pull, uniqBy } from 'lodash'
|
|
2
|
+
import { StackspotNotification } from '../../components/notification/types'
|
|
3
|
+
import { LazyNotificationListener, LoadNotificationsFilters, LoadNotificationsOptions, LoadResult } from './types'
|
|
4
|
+
|
|
5
|
+
interface ConstructorParams {
|
|
6
|
+
id: number,
|
|
7
|
+
load: (options: LoadNotificationsOptions) => Promise<LoadResult>,
|
|
8
|
+
filters?: LoadNotificationsFilters,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class LazyNotificationList {
|
|
12
|
+
readonly id: number
|
|
13
|
+
items: StackspotNotification[] = []
|
|
14
|
+
private page = -1
|
|
15
|
+
private total = 0
|
|
16
|
+
private filters: LoadNotificationsFilters
|
|
17
|
+
private readonly load: (options: LoadNotificationsOptions) => Promise<LoadResult>
|
|
18
|
+
private listeners: LazyNotificationListener[] = []
|
|
19
|
+
private currentFetch: Promise<void> | undefined
|
|
20
|
+
|
|
21
|
+
constructor({ id, load, filters = {} }: ConstructorParams) {
|
|
22
|
+
this.id = id
|
|
23
|
+
this.load = load
|
|
24
|
+
this.filters = filters
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private notify() {
|
|
28
|
+
const hasMore = this.hasMore()
|
|
29
|
+
this.listeners.forEach(l => l(this.items, hasMore))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async #loadMore(reset = false): Promise<void> {
|
|
33
|
+
try {
|
|
34
|
+
const result = await this.load({ ...this.filters, page: this.page + 1 })
|
|
35
|
+
if (reset) {
|
|
36
|
+
this.items = []
|
|
37
|
+
this.total = 0
|
|
38
|
+
}
|
|
39
|
+
// we can't have items with the same id: this can happen if new notifications have been created after the first page was loaded.
|
|
40
|
+
this.items = uniqBy([...this.items, ...result.items], 'id')
|
|
41
|
+
// we can't keep loading more if we already loaded every item or if the API returned an empty list.
|
|
42
|
+
this.total = result.items.length ? (this.total || result.total) : this.items.length
|
|
43
|
+
this.page++
|
|
44
|
+
this.notify()
|
|
45
|
+
} finally {
|
|
46
|
+
this.currentFetch = undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async applyFilters(filters: LoadNotificationsFilters) {
|
|
51
|
+
const prevPage = this.page
|
|
52
|
+
const prevFilters = this.filters
|
|
53
|
+
this.filters = filters
|
|
54
|
+
this.page = -1
|
|
55
|
+
try {
|
|
56
|
+
await this.currentFetch
|
|
57
|
+
this.currentFetch = this.#loadMore(true)
|
|
58
|
+
await this.currentFetch
|
|
59
|
+
} catch (error) {
|
|
60
|
+
this.filters = prevFilters
|
|
61
|
+
this.page = prevPage
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
loadMore() {
|
|
67
|
+
this.currentFetch ??= this.#loadMore()
|
|
68
|
+
return this.currentFetch
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
hasMore() {
|
|
72
|
+
return this.items.length < this.total
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
subscribe(listener: LazyNotificationListener) {
|
|
76
|
+
this.listeners.push(listener)
|
|
77
|
+
return () => {
|
|
78
|
+
pull(this.listeners, listener)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
update(readStatusMap: Map<string, boolean>) {
|
|
83
|
+
this.items.forEach(i => i.committed = readStatusMap.get(i.id) ?? i.committed)
|
|
84
|
+
if (this.filters.committed !== undefined) {
|
|
85
|
+
this.items = this.items.filter(i => i.committed === this.filters.committed)
|
|
86
|
+
// if the filtered list has one item or less, we update the list with the backend so it can show more items
|
|
87
|
+
if (this.items.length <= 1) this.applyFilters(this.filters)
|
|
88
|
+
}
|
|
89
|
+
this.notify()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
mute() {
|
|
93
|
+
this.listeners = []
|
|
94
|
+
}
|
|
95
|
+
}
|