@stack-spot/portal-components 2.0.3 → 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.
Files changed (129) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/components/AnimatedHeight.d.ts +59 -0
  3. package/dist/components/AnimatedHeight.d.ts.map +1 -0
  4. package/dist/components/AnimatedHeight.js +105 -0
  5. package/dist/components/AnimatedHeight.js.map +1 -0
  6. package/dist/components/Placeholder.d.ts +6 -4
  7. package/dist/components/Placeholder.d.ts.map +1 -1
  8. package/dist/components/Placeholder.js +5 -4
  9. package/dist/components/Placeholder.js.map +1 -1
  10. package/dist/components/TimelineSection.d.ts +25 -0
  11. package/dist/components/TimelineSection.d.ts.map +1 -0
  12. package/dist/components/TimelineSection.js +27 -0
  13. package/dist/components/TimelineSection.js.map +1 -0
  14. package/dist/components/error/ErrorFeedback.d.ts +9 -1
  15. package/dist/components/error/ErrorFeedback.d.ts.map +1 -1
  16. package/dist/components/error/ErrorFeedback.js +41 -4
  17. package/dist/components/error/ErrorFeedback.js.map +1 -1
  18. package/dist/components/form/SearchInput.d.ts +9 -0
  19. package/dist/components/form/SearchInput.d.ts.map +1 -0
  20. package/dist/components/form/SearchInput.js +28 -0
  21. package/dist/components/form/SearchInput.js.map +1 -0
  22. package/dist/components/form/Select.d.ts +69 -0
  23. package/dist/components/form/Select.d.ts.map +1 -0
  24. package/dist/components/form/Select.js +161 -0
  25. package/dist/components/form/Select.js.map +1 -0
  26. package/dist/components/{Notifications → notification}/NotificationComponent.d.ts +2 -1
  27. package/dist/components/notification/NotificationComponent.d.ts.map +1 -0
  28. package/dist/components/{Notifications → notification}/NotificationComponent.js +3 -1
  29. package/dist/components/notification/NotificationComponent.js.map +1 -0
  30. package/dist/components/notification/NotificationItem.d.ts +42 -0
  31. package/dist/components/notification/NotificationItem.d.ts.map +1 -0
  32. package/dist/components/{Notifications → notification}/NotificationItem.js +27 -12
  33. package/dist/components/notification/NotificationItem.js.map +1 -0
  34. package/dist/components/notification/NotificationList.d.ts +39 -0
  35. package/dist/components/notification/NotificationList.d.ts.map +1 -0
  36. package/dist/components/notification/NotificationList.js +82 -0
  37. package/dist/components/notification/NotificationList.js.map +1 -0
  38. package/dist/components/notification/NotificationPlaceholder.d.ts +12 -0
  39. package/dist/components/notification/NotificationPlaceholder.d.ts.map +1 -0
  40. package/dist/components/notification/NotificationPlaceholder.js +22 -0
  41. package/dist/components/notification/NotificationPlaceholder.js.map +1 -0
  42. package/dist/components/{Notifications → notification}/types.d.ts +16 -0
  43. package/dist/components/notification/types.d.ts.map +1 -0
  44. package/dist/components/{Notifications → notification}/types.js +3 -0
  45. package/dist/components/notification/types.js.map +1 -0
  46. package/dist/containers/NotificationsPage.d.ts +2 -0
  47. package/dist/containers/NotificationsPage.d.ts.map +1 -0
  48. package/dist/containers/NotificationsPage.js +58 -0
  49. package/dist/containers/NotificationsPage.js.map +1 -0
  50. package/dist/context/notification/LazyNotificationList.d.ts +28 -0
  51. package/dist/context/notification/LazyNotificationList.d.ts.map +1 -0
  52. package/dist/context/notification/LazyNotificationList.js +128 -0
  53. package/dist/context/notification/LazyNotificationList.js.map +1 -0
  54. package/dist/context/notification/NotificationController.d.ts +24 -0
  55. package/dist/context/notification/NotificationController.d.ts.map +1 -0
  56. package/dist/context/notification/NotificationController.js +136 -0
  57. package/dist/context/notification/NotificationController.js.map +1 -0
  58. package/dist/context/notification/context.d.ts +9 -0
  59. package/dist/context/notification/context.d.ts.map +1 -0
  60. package/dist/context/notification/context.js +12 -0
  61. package/dist/context/notification/context.js.map +1 -0
  62. package/dist/context/notification/hooks.d.ts +13 -0
  63. package/dist/context/notification/hooks.d.ts.map +1 -0
  64. package/dist/context/notification/hooks.js +77 -0
  65. package/dist/context/notification/hooks.js.map +1 -0
  66. package/dist/context/notification/types.d.ts +57 -0
  67. package/dist/context/notification/types.d.ts.map +1 -0
  68. package/dist/context/notification/types.js +2 -0
  69. package/dist/context/notification/types.js.map +1 -0
  70. package/dist/hooks/manual-render.d.ts +8 -0
  71. package/dist/hooks/manual-render.d.ts.map +1 -0
  72. package/dist/hooks/manual-render.js +10 -0
  73. package/dist/hooks/manual-render.js.map +1 -0
  74. package/dist/index.d.ts +2 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +2 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/notifications.d.ts +11 -0
  79. package/dist/notifications.d.ts.map +1 -0
  80. package/dist/notifications.js +10 -0
  81. package/dist/notifications.js.map +1 -0
  82. package/dist/svg/GenericPlaceholder.d.ts +5 -0
  83. package/dist/svg/GenericPlaceholder.d.ts.map +1 -0
  84. package/dist/svg/GenericPlaceholder.js +4 -0
  85. package/dist/svg/GenericPlaceholder.js.map +1 -0
  86. package/dist/svg/index.d.ts +1 -0
  87. package/dist/svg/index.d.ts.map +1 -1
  88. package/dist/svg/index.js +1 -0
  89. package/dist/svg/index.js.map +1 -1
  90. package/dist/utils/promise.d.ts +2 -0
  91. package/dist/utils/promise.d.ts.map +1 -0
  92. package/dist/utils/promise.js +6 -0
  93. package/dist/utils/promise.js.map +1 -0
  94. package/package.json +8 -4
  95. package/src/components/AnimatedHeight.tsx +174 -0
  96. package/src/components/Placeholder.tsx +13 -8
  97. package/src/components/TimelineSection.tsx +54 -0
  98. package/src/components/error/ErrorFeedback.tsx +93 -55
  99. package/src/components/form/SearchInput.tsx +69 -0
  100. package/src/components/form/Select.tsx +264 -0
  101. package/src/components/{Notifications → notification}/NotificationComponent.tsx +3 -1
  102. package/src/components/{Notifications → notification}/NotificationItem.tsx +76 -34
  103. package/src/components/notification/NotificationList.tsx +167 -0
  104. package/src/components/notification/NotificationPlaceholder.tsx +40 -0
  105. package/src/components/{Notifications → notification}/types.ts +21 -0
  106. package/src/containers/NotificationsPage.tsx +98 -0
  107. package/src/context/notification/LazyNotificationList.ts +95 -0
  108. package/src/context/notification/NotificationController.ts +104 -0
  109. package/src/context/notification/context.tsx +23 -0
  110. package/src/context/notification/hooks.ts +82 -0
  111. package/src/context/notification/types.ts +64 -0
  112. package/src/hooks/manual-render.tsx +10 -0
  113. package/src/index.ts +2 -1
  114. package/src/notifications.ts +11 -0
  115. package/src/svg/GenericPlaceholder.tsx +19 -0
  116. package/src/svg/index.ts +1 -0
  117. package/src/utils/promise.ts +5 -0
  118. package/dist/components/Notifications/NotificationComponent.d.ts.map +0 -1
  119. package/dist/components/Notifications/NotificationComponent.js.map +0 -1
  120. package/dist/components/Notifications/NotificationItem.d.ts +0 -17
  121. package/dist/components/Notifications/NotificationItem.d.ts.map +0 -1
  122. package/dist/components/Notifications/NotificationItem.js.map +0 -1
  123. package/dist/components/Notifications/index.d.ts +0 -4
  124. package/dist/components/Notifications/index.d.ts.map +0 -1
  125. package/dist/components/Notifications/index.js +0 -4
  126. package/dist/components/Notifications/index.js.map +0 -1
  127. package/dist/components/Notifications/types.d.ts.map +0 -1
  128. package/dist/components/Notifications/types.js.map +0 -1
  129. 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 {@link NotificationItemProps}.
42
+ * @param props the component's props.
42
43
  */
43
- const NotificationHeader = ({ notification, isSummary }: NotificationItemProps) => (
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
- {notification.title}
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
- call_to_action: string,
104
- onClickMarkReadUnread: (type: 'callToAction' | 'icon') => void,
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 = ({ call_to_action, onClickMarkReadUnread }: NotificationFooterProps) => {
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={() => onClickMarkReadUnread('callToAction')}
128
+ onClick={onClickAction}
122
129
  as={Link}
123
- href={call_to_action}
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 NotificationItemProps extends Props {
135
- onClickMarkReadUnread: (read: boolean, type: 'callToAction' | 'icon') => void,
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, onClickMarkReadUnread, id }: NotificationItemProps) => {
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' }} id={id}>
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 notification={notification} isSummary={isSummary} onClickMarkReadUnread={onClickMarkReadUnread}/>
197
+ <NotificationHeader title={notification.title} isSummary={isSummary} />
150
198
  <NotificationContent notification={notification} isSummary={isSummary} />
151
- {notification.call_to_action && <NotificationFooter call_to_action={notification.call_to_action}
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.markAsUnread : t.markAsRead} position="left"
156
- id={`tooltip-${notification.title}`} role="tooltip"
157
- sx={style.tooltip}>
158
- <IconButton aria-describedby={`tooltip-${notification.title}`}
159
- aria-live="polite"
160
- aria-label={notification.committed ? t.markAsUnread : t.markAsRead}
161
- sx={{ opacity: notification.committed ? 0.5 : 1 }}
162
- onClick={() => {
163
- onClickMarkReadUnread(notification.committed, 'icon')
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
- markAsRead: 'Mark as read',
184
- markAsUnread: 'Mark as unread',
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
- markAsRead: 'Marcar como lido',
194
- markAsUnread: 'Marcar como não lido',
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
+ }