Skip to content

Commit

Permalink
[kiworkshop#17] Notification center 구현 (kiworkshop#20)
Browse files Browse the repository at this point in the history
* [kiworkshop#17] Add a button to open a notification center

* [kiworkshop#17] Add a notification center

- TODO: render snackbars in the notification center

* [kiworkshop#17] Notification center almost implemented.

TODO: Add an animation when remove a notification

* [kiworkshop#17] Add animation for removing a notification
  • Loading branch information
myeongjae-kim authored Sep 8, 2019
1 parent baf8066 commit 2a1df1d
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 5 deletions.
2 changes: 2 additions & 0 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AnyAction, applyMiddleware, createStore, Middleware, Store } from 'redu
import theme from 'src/common/presentation/components/theme';
import ConfirmContainer from 'src/common/presentation/container/molecules/ConfirmContainer';
import SnackbarContainer from 'src/common/presentation/container/molecules/SnackbarContainer';
import NotificationCenterContainer from 'src/common/presentation/container/organisms/NotificationCenterContainer';
import CmsLayoutContainer from 'src/common/presentation/container/templates/CmsLayoutContainer';
import { setPaths } from 'src/common/presentation/state-module/common';
import { rootReducer, rootSaga, RootState } from 'src/common/presentation/state-module/root';
Expand Down Expand Up @@ -75,6 +76,7 @@ class MyApp extends App<AppProps> {
</CmsLayoutContainer>
<ConfirmContainer />
<SnackbarContainer />
<NotificationCenterContainer />
</SnackbarProvider>
</ReduxStoreProvider>
</ThemeProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Button, createStyles, makeStyles } from '@material-ui/core';
import { Notifications } from '@material-ui/icons';
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import * as snackbarModule from '../../state-module/snackbar';

const useStyles = makeStyles(createStyles({
icon: {
opacity: 0.8,
minWidth: 'initial'
}
}))

interface Props {
dispatchers: typeof snackbarModule
}

const NotificationCenterButton: React.FC<Props> = ({ dispatchers }) => {
const classes = useStyles();

return <Button
onClick={dispatchers.openNotificationCenter}
className={classes.icon}
size="small"
>
<Notifications />
</Button>
}

const mapDispatchToProps = (dispatch: Dispatch<snackbarModule.Action>) => ({
dispatchers: bindActionCreators(snackbarModule, dispatch)
})

export default connect(null, mapDispatchToProps)(NotificationCenterButton);
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createStyles, Drawer, makeStyles } from '@material-ui/core';
import * as React from 'react';
import { Snackbar } from '../../../state-module/snackbar';
import RenderNotification from './RenderNotification';

const useStyles = makeStyles(createStyles({
drawerItems: {
width: 340
},
paper: {
background: 'rgba(70, 70, 70, 0.8)',
}
}))

interface Props {
snackbars: Snackbar[]
opened: boolean
handleClose(): any
handleRemove(key: string): any
}

const NotificationCenter: React.SFC<Props> = ({ snackbars, opened, handleClose, handleRemove }) => {
const classes = useStyles();

return <Drawer
classes={{
paper: classes.paper
}}
anchor="right"
open={opened}
onClose={handleClose}
>
<div className={classes.drawerItems}>
{snackbars.map(s =>
<RenderNotification
key={s.key}
snackbar={s}
// tslint:disable-next-line: jsx-no-lambda
handleRemove={() => handleRemove(s.key)}
/>)}
</div>
</Drawer >
}

export default NotificationCenter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Card, createStyles, Fade, makeStyles, Theme, useTheme } from '@material-ui/core';
import { blue, green, grey, orange, red } from '@material-ui/core/colors';
import { Cancel } from '@material-ui/icons';
import { VariantType } from 'notistack';
import Optional from 'optional-js';
import * as React from 'react';
import { Snackbar } from 'src/common/presentation/state-module/snackbar';

const useStyles = makeStyles((theme: Theme) => createStyles({
card: {
margin: theme.spacing(1.5),
background: 'rgba(0, 0, 0, 0.35)',
color: theme.palette.primary.contrastText,
border: '0.5px solid #333',
cursor: 'default',
fontSize: '0.8rem'
},
titleWrapper: {
background: 'rgba(0, 0, 0, 0.20)',
display: 'flex',
justifyContent: 'space-between'
},
title: {
"& span:first-child": {
marginLeft: 7,
opacity: 0.9
},
"& span:not(:first-child)": {
color: grey[400],
margin: 3,
lineHeight: 2
}
},
notiContent: {
margin: `${theme.spacing(1.3)}px ${theme.spacing(2)}px`
},
message: {
marginBottom: theme.spacing(1)
},
messageOptions: {
color: grey[400],
whiteSpace: "pre"
},
cancel: {
color: grey[400],
fontSize: '1rem',
margin: '5px 8px 0px 5px',
'&:active': {
color: "#fff"
}
}
}))

interface Props {
snackbar: Snackbar
handleRemove(): any
}

const colorMap = new Map<VariantType, string>();
const colorWeight = 700;
colorMap.set("default", "initial");
colorMap.set("error", red[colorWeight]);
colorMap.set("info", blue[colorWeight]);
colorMap.set("success", green[colorWeight]);
colorMap.set("warning", orange[colorWeight]);

const RenderNotification: React.FC<Props> = ({ snackbar, handleRemove }) => {
const theme = useTheme();
const classes = useStyles();

const options = Optional.ofNullable(snackbar.options);

const variant = options.map(o => o.variant).orElse("default");
const title = options.map(o => o.title).map(t => `: ${t}`).orElse("");

const [isCancelButtonHidden, setIsCancelButtonHidden] = React.useState(true);
const showCancelButton = () => setIsCancelButtonHidden(false);
const hideCancelButton = () => setIsCancelButtonHidden(true);

const [checked, setChecked] = React.useState(true);
const fadeOut = () => setChecked(false);

const [ref] = React.useState(React.createRef<HTMLDivElement>());
const removeWithFadeoutAnimation = () => {
if (!ref.current) {
return;
}

const { leavingScreen } = theme.transitions.duration;

const initialHeight = ref.current.offsetHeight;
const deltaHeight = initialHeight / leavingScreen;

const initialMargin = theme.spacing(1.5);
const deltaMargin = initialMargin / leavingScreen;

ref.current.style.height = initialHeight + "px";
ref.current.style.margin = initialMargin + "px";

// Remove element
setTimeout(handleRemove, leavingScreen);
fadeOut();

// Animation
for (let i = 0; i < leavingScreen; i++) {
setTimeout(() => {
if (!ref.current) {
return;
}
ref.current.style.height = (initialHeight - deltaHeight * i) + "px"
ref.current.style.margin = (initialMargin - deltaMargin * i) + "px"
}, i);
}
}

return <>
<Fade in={checked}>
<Card
ref={ref}
className={classes.card}
onMouseOver={showCancelButton}
onMouseOut={hideCancelButton}
>
<div className={classes.titleWrapper}>
<div className={classes.title}>
<span style={{ color: colorMap.get(variant) }}></span>
<span>{variant}{title}</span>
</div>
<div hidden={isCancelButtonHidden} onMouseUp={removeWithFadeoutAnimation} >
<Cancel className={classes.cancel} />
</div>
</div>
<div className={classes.notiContent}>
<div className={classes.message}>
{snackbar.message}
</div>
<div className={classes.messageOptions}>
{Optional.ofNullable(snackbar.messageOptions).map((mo: any) => mo.e).orElse("")}
</div>
</div>
</Card>
</Fade>
</>;
}

export default RenderNotification;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './NotificationCenter'
6 changes: 5 additions & 1 deletion src/common/presentation/components/templates/CmsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import inversifyServices from 'src/inversifyServices';
import HorizontalMenuBarContainer from '../../container/molecules/HorizontalMenuBarContainer';
import LanguageToggleButton from '../atmos/LanguageToggleButton';
import Link from '../atmos/Link';
import NotificationCenterButton from '../atmos/NotificationCenterButton';

const drawerWidth = 240;
const horizontalMenuBarHeight = 31; /* manually calculate the height of horizonMenuBar */
Expand Down Expand Up @@ -163,7 +164,10 @@ const CmsLayout: React.FC<Props> = ({ children, t, paths, open, toggleOpen }) =>
{t('kiworkshop')} CMS
</Typography>
</MuiLink>
<LanguageToggleButton />
<div>
<NotificationCenterButton />
<LanguageToggleButton />
</div>
</div>
</Toolbar>
</AppBar>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class SnackbarContainer extends React.Component<Props> {
if (options.onClose) {
options.onClose(event, reason, keyOfOption);
}
}) as any
}) as any,
});
// Keep track of snackbars that we've displayed
this.storeDisplayed(key);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import NotificationCenter from '../../components/organisms/NotificationCenter';
import { RootState } from '../../state-module/root';
import * as snackbarModule from '../../state-module/snackbar';
import { Snackbar } from '../../state-module/snackbar';

interface Props {
isNotificationCenterOpened: boolean
snackbars: Snackbar[]
dispatchers: typeof snackbarModule
}

const NotificationCenterContainer: React.FC<Props> = ({
isNotificationCenterOpened: opened,
snackbars,
dispatchers,
}) => {
const handleClose = dispatchers.closeNotificationCenter;
const handleRemove = (key: string) => dispatchers.removeSnackbar({ key });

return <NotificationCenter
snackbars={snackbars}
opened={opened}
handleClose={handleClose}
handleRemove={handleRemove}
/>
}


const mapStateToProps = ({ snackbar }: RootState) => ({
isNotificationCenterOpened: snackbar.isNotificationCenterOpened,
snackbars: snackbar.snackbars
})

const mapDispatchToProps = (dispatch: Dispatch<snackbarModule.Action>) => ({
dispatchers: bindActionCreators(snackbarModule, dispatch)
})

export default connect(mapStateToProps, mapDispatchToProps)(NotificationCenterContainer);
27 changes: 24 additions & 3 deletions src/common/presentation/state-module/snackbar/snackbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,52 @@ import Optional from "optional-js";
import { ActionType, createReducer, createStandardAction, getType } from "typesafe-actions";
import uuid from 'uuid';

export interface SnackbarOptionsObject extends OptionsObject {
onClose?: any
remove?(): void
}

export interface SnackbarEnqueuePayload {
message: string | string[]
messageOptions?: TOptions | string
variant: VariantType
options?: OptionsObject | { onClose: any }
options?: SnackbarOptionsObject
}

export interface Snackbar {
key: string
message: string | string[]
messageOptions?: TOptions | string
dismissed?: boolean
options?: OptionsObject | { onClose: any }
options?: SnackbarOptionsObject
}

export const reset = createStandardAction("@snackbar/RESET")();

export const enqueueSnackbar = createStandardAction("@snackbar/ENQUEUE_SNACKBAR")<{ snackbar: SnackbarEnqueuePayload }>();
export const dismissSnackbar = createStandardAction("@snackbar/DISMISS_SNACKBAR")<{ key: string }>();
export const removeSnackbar = createStandardAction("@snackbar/REMOVE_SNACKBAR")<{ key: string }>();

export const openNotificationCenter = createStandardAction("@snackbar/OPEN_NOTIFICATION_CENTER")();
export const closeNotificationCenter = createStandardAction("@snackbar/CLOSE_NOTIFICATION_CENTER")();

export type Action = ActionType<
typeof reset |
typeof enqueueSnackbar |
typeof dismissSnackbar |
typeof removeSnackbar
typeof removeSnackbar |
typeof openNotificationCenter |
typeof closeNotificationCenter
>

export interface State {
snackbars: Snackbar[]
isNotificationCenterOpened: boolean
}

const createInitialState = (): State => ({
snackbars: [],
isNotificationCenterOpened: false
});

export const reducer = createReducer<State, Action>(createInitialState())
Expand All @@ -64,4 +77,12 @@ export const reducer = createReducer<State, Action>(createInitialState())
const { key } = action.payload;
draft.snackbars = draft.snackbars.filter(s => s.key !== key);
return draft
}))
.handleAction(getType(openNotificationCenter), (state) => produce(state, draft => {
draft.isNotificationCenterOpened = true;
return draft;
}))
.handleAction(getType(closeNotificationCenter), (state) => produce(state, draft => {
draft.isNotificationCenterOpened = false;
return draft;
}))
Loading

0 comments on commit 2a1df1d

Please sign in to comment.