Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Notifications Popup #402

Merged
merged 16 commits into from
Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/notification/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NotificationPopup as NotificationList } from './popup';
47 changes: 47 additions & 0 deletions src/components/notification/item/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Properties> = {}) => {
const allProps = {
body: '',
createdAt: '',
...props,
};

return shallow(<NotificationItem {...allProps} />);
};

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');
});
});
43 changes: 43 additions & 0 deletions src/components/notification/item/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Properties> {
get time() {
return moment(this.props.createdAt).fromNow();
}

render() {
return (
<div
className={classNames('notification-item__wrapper', {
'notification-item__wrapper--not-read': this.props.notRead,
})}
>
<div className='notification-item__avatar'>
<Avatar
userFriendlyName={this.props.originatingName}
type='circle'
imageURL={this.props.originatingImageUrl}
size='medium'
/>
</div>
<div className='notification-item__content'>
<p>{this.props.body}</p>
<span className='notification-item__timestamp'>{this.time}</span>
</div>
</div>
);
}
}
33 changes: 33 additions & 0 deletions src/components/notification/item/style.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
175 changes: 175 additions & 0 deletions src/components/notification/list/container.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Properties> = {}) => {
const allProps = {
notifications: [],
userId: '',
fetchNotifications: () => undefined,
...props,
};

return shallow(<Container {...allProps} />);
};

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<RootState>) => {
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<RootState>) => {
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');
});
});
});
});
73 changes: 73 additions & 0 deletions src/components/notification/list/container.tsx
Original file line number Diff line number Diff line change
@@ -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<Properties> {
static mapState(state: RootState): Partial<Properties> {
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<Properties> {
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 <NotificationList list={this.props.notifications} />;
}
}

export const NotificationListContainer = connectContainer<{}>(Container);
Loading