Skip to content

Commit

Permalink
Merge pull request #636 from NordicSemiconductor/feat/flash-messages
Browse files Browse the repository at this point in the history
feat: Flash Messages
  • Loading branch information
aadnekar authored Jun 22, 2023
2 parents 053bd30 + 27a73f1 commit b6b2211
Show file tree
Hide file tree
Showing 13 changed files with 354 additions and 2 deletions.
5 changes: 5 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ every new version is a new major version.
- Exported `getWaitForDevice` hance allowing apps to resort previous state if
needed

### Added

- Flash Messages feature. Comes with convenience thunk functions in order to
create info, success, warning, and error messages.

### Changed

- FeedbackPane: Change **Platform** to **Operating system**, and remove the
Expand Down
19 changes: 17 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
"dependencies": {
"@electron/remote": "^2.0.4",
"@mdi/font": "7.2.96",
"@mdi/js": "^7.2.96",
"@mdi/react": "^1.6.1",
"@reduxjs/toolkit": "1.9.3",
"@svgr/core": "^7.0.0",
"@svgr/plugin-jsx": "7.0.0",
Expand Down
2 changes: 2 additions & 0 deletions src/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '../Device/deviceSlice';
import ErrorBoundary from '../ErrorBoundary/ErrorBoundary';
import ErrorDialog from '../ErrorDialog/ErrorDialog';
import FlashMessages from '../FlashMessage/FlashMessage';
import LogViewer from '../Log/LogViewer';
import NavBar from '../NavBar/NavBar';
import classNames from '../utils/classNames';
Expand Down Expand Up @@ -166,6 +167,7 @@ const ConnectedApp: FC<ConnectedAppProps> = ({
<LogViewer />
</div>
</div>
<FlashMessages />
</div>
<VisibilityBar isSidePanelEnabled={sidePanel !== null} />

Expand Down
154 changes: 154 additions & 0 deletions src/FlashMessage/FlashMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright (c) 2023 Nordic Semiconductor ASA
*
* SPDX-License-Identifier: LicenseRef-Nordic-4-Clause
*/

/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { mdiClose } from '@mdi/js';
import Icon from '@mdi/react';

import { colors } from '../utils/colors';
import {
FlashMessage,
FlashMessageVariant,
getMessages,
removeMessage,
} from './FlashMessageSlice';

import './flashMessage.css';

interface FlashMessageProps {
flashMessage: FlashMessage;
}

const FlashMessage = ({ flashMessage }: FlashMessageProps) => {
const { id, message, variant, dismissTime } = flashMessage;

const dispatch = useDispatch();
const divRef = useRef(null);
const [fadeoutTimer, setFadeoutTimer] = useState<string>(
dismissTime == null ? 'unset' : `${dismissTime}ms`
);
const timeoutHandler = useRef<NodeJS.Timeout | undefined>(undefined);

if (timeoutHandler.current == null && dismissTime != null) {
timeoutHandler.current = setTimeout(() => {
dispatch(removeMessage({ id }));
}, dismissTime);
}

const close = () => {
clearTimeout(timeoutHandler.current);
dispatch(removeMessage({ id }));
};

const addFadeout = () => {
if (dismissTime) {
timeoutHandler.current = setTimeout(() => {
dispatch(removeMessage({ id }));
}, dismissTime);
setFadeoutTimer(`${dismissTime}ms`);
}
};

const removeFadeout = () => {
clearTimeout(timeoutHandler.current);
setFadeoutTimer('unset');
};

const initialRender = () => divRef.current == null;

return (
<div
ref={divRef}
style={{
backgroundColor: getBackgroundColorFromVariant(variant),
color: colors.white,
zIndex: 1000,
animation: initialRender() ? 'slide-in 1s' : 'unset',
width: '100%',
padding: '16px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
onMouseEnter={removeFadeout}
onMouseLeave={addFadeout}
>
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
}}
>
{message}
<div onClick={close}>
<Icon path={mdiClose} size={0.8} />
</div>
</div>
{dismissTime != null ? (
<div
style={{
backgroundColor: colors.white,
width: 'calc(100% + 32px)',
height: '2px',
margin: '-16px',
marginTop: '8px',
animation:
fadeoutTimer !== 'unset'
? `flash-message ${dismissTime}ms linear`
: 'unset',
}}
/>
) : null}
</div>
);
};

const FlashMessages = () => {
const messages = useSelector(getMessages);

if (messages.length === 0) return null;

return (
<div
style={{
width: '256px',
gap: '16px',
marginBottom: '32px',
display: 'flex',
flexDirection: 'column-reverse',
}}
className="message-container"
>
{messages.map(flashMessage => (
<FlashMessage
key={flashMessage.id}
flashMessage={flashMessage}
/>
))}
</div>
);
};

const getBackgroundColorFromVariant = (variant: FlashMessageVariant) => {
switch (variant) {
case 'error':
return colors.red;
case 'success':
return colors.green;
case 'info':
return colors.nordicBlue;
case 'warning':
return colors.orange;

default:
return '' as never;
}
};
export default FlashMessages;
101 changes: 101 additions & 0 deletions src/FlashMessage/FlashMessageSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2023 Nordic Semiconductor ASA
*
* SPDX-License-Identifier: LicenseRef-Nordic-4-Clause
*/

import {
AnyAction,
createSlice,
nanoid,
PayloadAction,
ThunkAction,
} from '@reduxjs/toolkit';

import type { RootState } from '../store';

export interface FlashMessages {
messages: FlashMessage[];
}

export type FlashMessageVariant = 'success' | 'warning' | 'error' | 'info';

export interface FlashMessage {
id: string;
message: string;
variant: FlashMessageVariant;
dismissTime?: number;
}

export type FlashMessagePayload = Omit<FlashMessage, 'id'>;

const initialState: FlashMessages = {
messages: [],
};

const slice = createSlice({
name: 'flashMessages',
initialState,
reducers: {
addNewMessage: {
reducer: (state, action: PayloadAction<FlashMessage>) => {
state.messages.push(action.payload);
},
prepare: (message: FlashMessagePayload) => ({
payload: {
id: nanoid(),
...message,
},
}),
},
removeMessage: (state, action: PayloadAction<{ id: string }>) => {
state.messages = state.messages.filter(
message => message.id !== action.payload.id
);
},
},
});

type TAction = ThunkAction<void, RootState, null, AnyAction>;

export const newCopiedFlashMessage = (): TAction => dispatch =>
dispatch(newInfoFlashMessage('Copied to clipboard!', 12000));

export const newSuccessFlashMessage =
(message: string, dismissTime?: number): TAction =>
dispatch =>
dispatch(newFlashMessage({ message, variant: 'success', dismissTime }));

export const newWarningFlashMessage =
(message: string, dismissTime?: number): TAction =>
dispatch =>
dispatch(newFlashMessage({ message, variant: 'warning', dismissTime }));

export const newErrorFlashMessage =
(message: string, dismissTime?: number): TAction =>
dispatch =>
dispatch(newFlashMessage({ message, variant: 'error', dismissTime }));

export const newInfoFlashMessage =
(message: string, dismissTime?: number): TAction =>
dispatch =>
dispatch(newFlashMessage({ message, variant: 'info', dismissTime }));

const newFlashMessage =
({ message, variant, dismissTime }: FlashMessagePayload): TAction =>
dispatch => {
dispatch(
addNewMessage({
message,
variant,
dismissTime,
})
);
};

export const getMessages = (state: RootState) => state.flashMessages.messages;

export const {
reducer,
actions: { addNewMessage, removeMessage },
} = slice;
23 changes: 23 additions & 0 deletions src/FlashMessage/flashMessage.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 Nordic Semiconductor ASA
*
* SPDX-License-Identifier: LicenseRef-Nordic-4-Clause
*/

@keyframes flash-message {
from {
width: 100%;
}
to {
width: 0;
}
}

@keyframes slide-in {
from {
transform: translateX(200%);
}
to {
transform: translateX(0);
}
}
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,12 @@ export type {
} from './utils/AppTypes';

export { default as FeedbackPane } from './Panes/FeedbackPane';

export {
addNewMessage,
newCopiedFlashMessage,
newInfoFlashMessage,
newWarningFlashMessage,
newErrorFlashMessage,
newSuccessFlashMessage,
} from './FlashMessage/FlashMessageSlice';
2 changes: 2 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { reducer as deviceAutoSelect } from './Device/deviceAutoSelectSlice';
import { reducer as deviceSetup } from './Device/deviceSetupSlice';
import { reducer as device } from './Device/deviceSlice';
import { reducer as errorDialog } from './ErrorDialog/errorDialogSlice';
import { reducer as flashMessages } from './FlashMessage/FlashMessageSlice';
import { reducer as log } from './Log/logSlice';

const ifBuiltForDevelopment = <X>(value: X) =>
Expand All @@ -33,6 +34,7 @@ export const rootReducerSpec = (appReducer: Reducer = noopReducer) => ({
log,
documentation,
shortcuts,
flashMessages,
});

const store = (appReducer?: Reducer) =>
Expand Down
4 changes: 4 additions & 0 deletions typings/generated/src/FlashMessage/FlashMessage.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// <reference types="react" />
import './flashMessage.css';
declare const FlashMessages: () => JSX.Element | null;
export default FlashMessages;
Loading

0 comments on commit b6b2211

Please sign in to comment.