diff --git a/src/components/notification/index.tsx b/src/components/notification/index.tsx new file mode 100644 index 000000000..5a9bb212a --- /dev/null +++ b/src/components/notification/index.tsx @@ -0,0 +1 @@ +export { NotificationPopup as NotificationList } from './popup'; diff --git a/src/components/notification/item/index.test.tsx b/src/components/notification/item/index.test.tsx new file mode 100644 index 000000000..a16ac5d89 --- /dev/null +++ b/src/components/notification/item/index.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { NotificationItem, Properties } from '.'; +import moment from 'moment'; + +describe('NotificationItem', () => { + const subject = (props: Partial = {}) => { + const allProps = { + body: '', + createdAt: '', + ...props, + }; + + return shallow(); + }; + + it('renders the message', () => { + const wrapper = subject({ + body: 'Here is the description', + }); + + expect(wrapper.find('p').text()).toEqual('Here is the description'); + }); + + it('renders created timestamp', () => { + const wrapper = subject({ + createdAt: '2023-03-10T22:33:34.945Z', + }); + + const expectedTimeDescription = moment('2023-03-10T22:33:34.945Z').fromNow(); + + expect(wrapper.find('.notification-item__timestamp').text()).toEqual(expectedTimeDescription); + }); + + it('renders Avatar', () => { + const wrapper = subject({ + originatingName: 'Originating Name', + originatingImageUrl: 'image-url', + }); + + const avatar = wrapper.find('Avatar'); + + expect(avatar.prop('userFriendlyName')).toEqual('Originating Name'); + expect(avatar.prop('imageURL')).toEqual('image-url'); + }); +}); diff --git a/src/components/notification/item/index.tsx b/src/components/notification/item/index.tsx new file mode 100644 index 000000000..226b7c0e3 --- /dev/null +++ b/src/components/notification/item/index.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Avatar } from '@zero-tech/zui/components/Avatar'; + +import './style.scss'; +import moment from 'moment'; + +export interface Properties { + body: string; + createdAt: string; + originatingName?: string; + originatingImageUrl?: string; + notRead?: boolean; +} + +export class NotificationItem extends React.Component { + get time() { + return moment(this.props.createdAt).fromNow(); + } + + render() { + return ( +
+
+ +
+
+

{this.props.body}

+ {this.time} +
+
+ ); + } +} diff --git a/src/components/notification/item/style.scss b/src/components/notification/item/style.scss new file mode 100644 index 000000000..6d3b09a27 --- /dev/null +++ b/src/components/notification/item/style.scss @@ -0,0 +1,33 @@ +@use '~@zero-tech/zui/styles/theme' as theme; + +.notification-item { + &__wrapper { + display: flex; + padding: 16px 16px; + } + + &__avatar { + margin-right: 16px; + } + + &__content { + p { + color: theme.$color-greyscale-12; + + margin: 0px; + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 17px; + } + } + + &__timestamp { + color: theme.$color-greyscale-11; + + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 15px; + } +} diff --git a/src/components/notification/list/container.test.tsx b/src/components/notification/list/container.test.tsx new file mode 100644 index 000000000..ab98630c7 --- /dev/null +++ b/src/components/notification/list/container.test.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { RootState } from '../../../store'; +import { AsyncListStatus } from '../../../store/normalized'; + +import { Container, Properties } from './container'; + +describe('NotificationsListContainer', () => { + const subject = (props: Partial = {}) => { + const allProps = { + notifications: [], + userId: '', + fetchNotifications: () => undefined, + ...props, + }; + + return shallow(); + }; + + it('passes notifications to the NotificationList', () => { + const notifications = [ + { a: 'notification' }, + { b: 'notification' }, + ]; + const wrapper = subject({ notifications }); + + expect(wrapper.find('NotificationList').prop('list')).toEqual(notifications); + }); + + it('fetches the notifications when rendered', () => { + const fetchNotifications = jest.fn(); + + subject({ fetchNotifications }); + + expect(fetchNotifications).toHaveBeenCalledOnce(); + }); + + describe('mapState', () => { + const subject = (state: Partial) => { + return Container.mapState({ + authentication: { user: { data: { id: 'user-id' } as any } }, + notificationsList: { value: [] }, + ...state, + } as RootState); + }; + + test('notifications', () => { + const state = subject({ + notificationsList: { + status: AsyncListStatus.Idle, + value: [ + 'id-1', + 'id-2', + ], + }, + normalized: { + notifications: { + 'id-1': { id: 'id-1', notificationType: 'chat_channel_mention' }, + 'id-2': { id: 'id-2', notificationType: 'chat_channel_mention' }, + }, + }, + }); + + expect(state.notifications).toIncludeAllPartialMembers([ + { id: 'id-1' }, + { id: 'id-2' }, + ]); + }); + + test('userId', () => { + const state = subject({ + authentication: { user: { data: { id: 'user-id' } as any } }, + }); + + expect(state.userId).toEqual('user-id'); + }); + }); + + describe('mapNotification', () => { + const subject = (notification = {}, state: Partial) => { + return Container.mapNotification(notification, state as RootState); + }; + + describe('unknown type', () => { + it('maps body with a known channel', () => { + const mappedNotification = subject({ notificationType: 'unknown_type' }, {}); + + expect(mappedNotification).toBeNull(); + }); + }); + + describe('chat_channel_mention', () => { + it('maps body with a known channel', () => { + const mappedNotification = subject( + { + notificationType: 'chat_channel_mention', + data: { chatId: 'chat-id' }, + originUser: { + profileSummary: { + firstName: 'Johnny', + lastName: 'Chatter', + profileImage: 'image-url', + }, + }, + }, + { + normalized: { + channels: { + 'chat-id': { id: 'chat-id', name: 'TestingChannel' }, + }, + }, + } + ); + + expect(mappedNotification.body).toEqual('Johnny Chatter mentioned you in #TestingChannel'); + }); + + it('maps body with unknown info', () => { + const mappedNotification = subject( + { + notificationType: 'chat_channel_mention', + data: { chatId: 'chat-id' }, + }, + { + normalized: { + channels: {}, + }, + } + ); + + expect(mappedNotification.body).toEqual('Someone mentioned you in a channel'); + }); + + it('maps default properties', () => { + const mappedNotification = subject( + { + id: 'notification-id', + notificationType: 'chat_channel_mention', + data: { chatId: 'chat-id' }, + createdAt: '2023-01-20T22:33:34.945Z', + }, + { + normalized: { channels: {} }, + } + ); + + expect(mappedNotification.id).toEqual('notification-id'); + expect(mappedNotification.createdAt).toEqual('2023-01-20T22:33:34.945Z'); + }); + + it('maps sender', () => { + const mappedNotification = subject( + { + notificationType: 'chat_channel_mention', + data: {}, + originUser: { + profileSummary: { + firstName: 'first', + lastName: 'Last', + profileImage: 'image-url', + }, + }, + }, + { + normalized: { channels: {} }, + } + ); + + expect(mappedNotification.originatingName).toEqual('first Last'); + expect(mappedNotification.originatingImageUrl).toEqual('image-url'); + }); + }); + }); +}); diff --git a/src/components/notification/list/container.tsx b/src/components/notification/list/container.tsx new file mode 100644 index 000000000..7b3b2ee98 --- /dev/null +++ b/src/components/notification/list/container.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import { connectContainer } from '../../../store/redux-container'; +import { RootState } from '../../../store'; +import { fetch as fetchNotifications, denormalize } from '../../../store/notifications'; +import { denormalize as denormalizeChannel } from '../../../store/channels'; + +import { NotificationList } from '.'; + +export interface Properties { + notifications: any[]; + userId: string; + fetchNotifications: (payload: { userId: string }) => void; +} + +export class Container extends React.Component { + static mapState(state: RootState): Partial { + const { + authentication: { user }, + } = state; + const notifications = denormalize(state.notificationsList.value, state) + .map((n) => Container.mapNotification(n, state)) + .filter((n) => !!n); + + return { + notifications, + userId: user?.data?.id, + }; + } + + static mapActions(_props: Properties): Partial { + return { + fetchNotifications, + }; + } + + static mapNotification(notification, state: RootState) { + if (notification.notificationType === 'chat_channel_mention') { + const channelId = notification.data?.chatId; + const { name: channelName } = denormalizeChannel(channelId, state) || {}; + const channelText = channelName ? `#${channelName}` : 'a channel'; + + // This should probably be extracted to a display utility or added + // to the domain model + let displayName = [ + notification.originUser?.profileSummary?.firstName, + notification.originUser?.profileSummary?.lastName, + ] + .filter((e) => e) + .join(' '); + displayName = displayName || 'Someone'; + + return { + id: notification.id, + createdAt: notification.createdAt, + body: `${displayName} mentioned you in ${channelText}`, + originatingName: displayName, + originatingImageUrl: notification.originUser?.profileSummary?.profileImage, + }; + } + return null; + } + + componentDidMount() { + this.props.fetchNotifications({ userId: this.props.userId }); + } + + render() { + return ; + } +} + +export const NotificationListContainer = connectContainer<{}>(Container); diff --git a/src/components/notification/list/index.test.tsx b/src/components/notification/list/index.test.tsx new file mode 100644 index 000000000..8c1e26fac --- /dev/null +++ b/src/components/notification/list/index.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { NotificationList, Properties } from '.'; + +describe('NotificationList', () => { + const subject = (props: Partial = {}) => { + const allProps = { + list: [], + ...props, + }; + + return shallow(); + }; + + it('renders the list', () => { + const wrapper = subject({ + list: [ + { id: 'id-1', body: 'body-1', createdAt: '2023-03-13T22:33:34.945Z' }, + { id: 'id-2', body: 'body-2', createdAt: '2023-01-20T22:33:34.945Z' }, + ], + }); + + expect(wrapper.find('NotificationItem').map((n) => n.props())).toEqual([ + { body: 'body-1', createdAt: '2023-03-13T22:33:34.945Z' }, + { body: 'body-2', createdAt: '2023-01-20T22:33:34.945Z' }, + ]); + }); +}); diff --git a/src/components/notification/list/index.tsx b/src/components/notification/list/index.tsx new file mode 100644 index 000000000..a3e5cf5b0 --- /dev/null +++ b/src/components/notification/list/index.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { NotificationItem } from '../item'; + +import './style.scss'; + +interface Notification { + id: string; + body: string; + createdAt: string; + originatingName?: string; + originatingImageUrl?: string; +} + +export interface Properties { + list?: Notification[]; +} + +export class NotificationList extends React.Component { + render() { + return ( +
+
+ {this.props.list.map((n) => ( + + ))} +
+
+ ); + } +} diff --git a/src/components/notification/list/style.scss b/src/components/notification/list/style.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/notification/popup/index.tsx b/src/components/notification/popup/index.tsx new file mode 100644 index 000000000..6f8a9a1d3 --- /dev/null +++ b/src/components/notification/popup/index.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; + +import { NotificationListContainer } from '../list/container'; + +import './style.scss'; + +export interface Properties {} + +export class NotificationPopup extends React.Component { + render() { + return <>{createPortal(this.renderPopup(), document.body)}; + } + + renderPopup() { + return ( +
+
+

Notifications

+
+ +
+ ); + } +} diff --git a/src/components/notification/popup/style.scss b/src/components/notification/popup/style.scss new file mode 100644 index 000000000..1fa909289 --- /dev/null +++ b/src/components/notification/popup/style.scss @@ -0,0 +1,27 @@ +@use '~@zero-tech/zui/styles/theme' as theme; + +.notification-popup { + position: absolute; + top: 75px; + right: 20px; + z-index: 500; + background-color: theme.$color-primary-2; + width: 360px; + padding: 0px 16px; + + // Elevation 3 + box-shadow: 12px 11px 54px #040304; + border-radius: 16px 0px 16px 16px; + + font-family: 'Inter'; + + h3 { + color: theme.$color-white; + margin: 20px 0px; + + font-style: normal; + font-weight: 700; + font-size: 16px; + line-height: 24px; + } +} diff --git a/src/components/sidekick/index.test.tsx b/src/components/sidekick/index.test.tsx index b036502a3..712ea3244 100644 --- a/src/components/sidekick/index.test.tsx +++ b/src/components/sidekick/index.test.tsx @@ -97,7 +97,7 @@ describe('Sidekick', () => { const wrapper = subject({ activeTab: SidekickTabs.NOTIFICATIONS }); wrapper.find('.sidekick__tabs-notifications').simulate('click'); - expect(wrapper.find('.sidekick__tab-content--notifications').exists()).toBe(true); + expect(wrapper.find('NotificationPopup').exists()).toBe(true); }); it('render messages tab content', () => { diff --git a/src/components/sidekick/index.tsx b/src/components/sidekick/index.tsx index 5c512824b..0f2514c95 100644 --- a/src/components/sidekick/index.tsx +++ b/src/components/sidekick/index.tsx @@ -17,6 +17,7 @@ import { denormalize } from '../../store/channels'; import { SidekickTabs as Tabs } from './types'; import './styles.scss'; +import { NotificationList } from '../notification'; interface PublicProperties { className?: string; @@ -159,7 +160,7 @@ export class Container extends React.Component { ); case Tabs.NOTIFICATIONS: - return
NOTIFICATIONS
; + return ; default: return null; } diff --git a/src/lib/api/rest.ts b/src/lib/api/rest.ts index 365c4f2c7..cf7c94b26 100644 --- a/src/lib/api/rest.ts +++ b/src/lib/api/rest.ts @@ -24,7 +24,7 @@ function apiUrl(path: string): string { */ const xPlatFormHeader = { 'X-APP-PLATFORM': 'zos' }; -export function get(path: string, filter?: RequestFilter | string) { +export function get(path: string, filter?: RequestFilter | string, query?: any) { let queryData; if (filter) { if (typeof filter === 'string') { @@ -36,6 +36,10 @@ export function get(path: string, filter?: RequestFilter | string) { } } + if (query) { + queryData = { ...queryData, ...query }; + } + return Request.get(apiUrl(path)).set(xPlatFormHeader).withCredentials().query(queryData); } diff --git a/src/store/index.ts b/src/store/index.ts index f04307c17..f42e0e8a0 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -13,6 +13,7 @@ import { reducer as apps } from './apps'; import { reducer as normalized } from './normalized'; import { reducer as authentication } from './authentication'; import { reducer as chat } from './chat'; +import { reducer as notificationsList } from './notifications'; const sagaMiddleware = createSagaMiddleware({ onError: (e) => { @@ -30,6 +31,7 @@ export const rootReducer = combineReducers({ normalized, authentication, chat, + notificationsList, }); export type RootState = ReturnType; diff --git a/src/store/notifications/api.ts b/src/store/notifications/api.ts new file mode 100644 index 000000000..19d97b88b --- /dev/null +++ b/src/store/notifications/api.ts @@ -0,0 +1,6 @@ +import { get } from '../../lib/api/rest'; + +export async function fetchNotifications(userId: string) { + const response = await get('/api/notifications/filter', { limit: 15 }, { userId }); + return response.body; +} diff --git a/src/store/notifications/index.test.ts b/src/store/notifications/index.test.ts new file mode 100644 index 000000000..829d72d33 --- /dev/null +++ b/src/store/notifications/index.test.ts @@ -0,0 +1,31 @@ +import { reducer, receiveNormalized, setStatus } from '.'; +import { AsyncListStatus, AsyncNormalizedListState } from '../normalized'; + +describe('notificationsList reducer', () => { + const initialExistingState: AsyncNormalizedListState = { + status: AsyncListStatus.Idle, + value: [], + }; + + it('should handle initial state', () => { + expect(reducer(undefined, { type: 'unknown' })).toEqual({ + status: AsyncListStatus.Idle, + value: [], + }); + }); + + it('should replace existing state with new state', () => { + const actual = reducer(initialExistingState, receiveNormalized(['the-id'])); + + expect(actual).toStrictEqual({ + value: ['the-id'], + status: AsyncListStatus.Idle, + }); + }); + + it('should replace existing status with new status', () => { + const actual = reducer(initialExistingState, setStatus(AsyncListStatus.Fetching)); + + expect(actual.status).toEqual(AsyncListStatus.Fetching); + }); +}); diff --git a/src/store/notifications/index.ts b/src/store/notifications/index.ts new file mode 100644 index 000000000..cea4a1a41 --- /dev/null +++ b/src/store/notifications/index.ts @@ -0,0 +1,26 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { createNormalizedListSlice, createNormalizedSlice } from '../normalized'; + +import { Payload } from './saga'; + +export enum SagaActionTypes { + Fetch = 'notifications/saga/fetch', +} + +const fetch = createAction(SagaActionTypes.Fetch); + +const slice = createNormalizedSlice({ + name: 'notifications', +}); +const { schema } = slice; + +const listSlice = createNormalizedListSlice({ + name: 'notificationsList', + schema, +}); + +export const { receiveNormalized, setStatus, receive } = listSlice.actions; +export const { reducer, normalize, denormalize } = listSlice; + +export { fetch }; diff --git a/src/store/notifications/saga.test.ts b/src/store/notifications/saga.test.ts new file mode 100644 index 000000000..f46ea461d --- /dev/null +++ b/src/store/notifications/saga.test.ts @@ -0,0 +1,100 @@ +import { expectSaga } from 'redux-saga-test-plan'; +import * as matchers from 'redux-saga-test-plan/matchers'; + +import { AsyncListStatus } from '../normalized'; +import { rootReducer } from '..'; + +import { fetch } from './saga'; +import { setStatus } from '.'; +import { fetchNotifications } from './api'; + +describe('notifications list saga', () => { + it('sets status to fetching', async () => { + await expectSaga(fetch, { payload: {} }) + .put(setStatus(AsyncListStatus.Fetching)) + .provide([ + [ + matchers.call.fn(fetchNotifications), + [], + ], + ]) + .run(); + }); + + it('fetches notifications', async () => { + await expectSaga(fetch, { payload: { userId: 'user-id' } }) + .provide([ + [ + matchers.call.fn(fetchNotifications), + [], + ], + ]) + .call(fetchNotifications, 'user-id') + .run(); + }); + + it('sets status to Idle', async () => { + const { + storeState: { notificationsList }, + } = await expectSaga(fetch, { payload: {} }) + .withReducer(rootReducer) + .provide([ + [ + matchers.call.fn(fetchNotifications), + [], + ], + ]) + .run(); + + expect(notificationsList.status).toBe(AsyncListStatus.Idle); + }); + + it('adds notification ids to notificationsList state', async () => { + const ids = [ + 'id-1', + 'id-2', + 'id-3', + ]; + const { + storeState: { notificationsList }, + } = await expectSaga(fetch, { payload: {} }) + .withReducer(rootReducer) + .provide([ + [ + matchers.call.fn(fetchNotifications), + [ + { id: 'id-1', notificationType: 'type-1' }, + { id: 'id-2', notificationType: 'type-2' }, + { id: 'id-3', notificationType: 'type-3' }, + ], + ], + ]) + .run(); + + expect(notificationsList.value).toStrictEqual(ids); + }); + + it('adds notifications to normalized state', async () => { + const { + storeState: { normalized }, + } = await expectSaga(fetch, { payload: {} }) + .provide([ + [ + matchers.call.fn(fetchNotifications), + [ + { id: 'id-1', notificationType: 'type-1' }, + { id: 'id-2', notificationType: 'type-2' }, + { id: 'id-3', notificationType: 'type-3' }, + ], + ], + ]) + .withReducer(rootReducer) + .run(); + + expect(normalized.notifications).toStrictEqual({ + 'id-1': { id: 'id-1', notificationType: 'type-1' }, + 'id-2': { id: 'id-2', notificationType: 'type-2' }, + 'id-3': { id: 'id-3', notificationType: 'type-3' }, + }); + }); +}); diff --git a/src/store/notifications/saga.ts b/src/store/notifications/saga.ts new file mode 100644 index 000000000..dcac3dfe3 --- /dev/null +++ b/src/store/notifications/saga.ts @@ -0,0 +1,26 @@ +import { takeLatest, put, call } from 'redux-saga/effects'; + +import { AsyncListStatus } from '../normalized'; + +import { SagaActionTypes, receive, setStatus } from '.'; +import { fetchNotifications } from './api'; + +export interface Payload { + userId: string; +} + +export function* fetch(action) { + const { userId } = action.payload; + + yield put(setStatus(AsyncListStatus.Fetching)); + + const notifications = yield call(fetchNotifications, userId); + + yield put(receive(notifications)); + + yield put(setStatus(AsyncListStatus.Idle)); +} + +export function* saga() { + yield takeLatest(SagaActionTypes.Fetch, fetch); +} diff --git a/src/store/notifications/types.ts b/src/store/notifications/types.ts new file mode 100644 index 000000000..a8d773bd6 --- /dev/null +++ b/src/store/notifications/types.ts @@ -0,0 +1,18 @@ +import { Channel, User } from '../channels'; + +export interface Payload { + channelId: string; +} + +export enum ChannelType { + Channel, + DirectMessage, +} + +export interface DirectMessage extends Channel { + otherMembers: User[]; +} + +export interface CreateMessengerConversation { + userIds: string[]; +} diff --git a/src/store/saga.ts b/src/store/saga.ts index 6ec49fe9f..eeb603561 100644 --- a/src/store/saga.ts +++ b/src/store/saga.ts @@ -10,6 +10,7 @@ import { saga as authentication } from './authentication/saga'; import { saga as chat } from './chat/saga'; import { saga as theme } from './theme/saga'; import { saga as layout } from './layout/saga'; +import { saga as notificationsList } from './notifications/saga'; export function* rootSaga() { const allSagas = { @@ -23,6 +24,7 @@ export function* rootSaga() { chat, theme, layout, + notificationsList, }; yield all(