diff --git a/client/src/components/Allocations/AllocationsLayout.jsx b/client/src/components/Allocations/AllocationsLayout.jsx index afc790b23..70623303d 100644 --- a/client/src/components/Allocations/AllocationsLayout.jsx +++ b/client/src/components/Allocations/AllocationsLayout.jsx @@ -68,7 +68,7 @@ export const Layout = ({ page }) => { return (
} headerClassName="allocations-header" headerActions={} diff --git a/client/src/components/Applications/AppLayout/AppLayout.jsx b/client/src/components/Applications/AppLayout/AppLayout.jsx index 045111a05..68767e1f2 100644 --- a/client/src/components/Applications/AppLayout/AppLayout.jsx +++ b/client/src/components/Applications/AppLayout/AppLayout.jsx @@ -60,7 +60,7 @@ const AppsRoutes = () => { return (
diff --git a/client/src/components/Dashboard/Dashboard.jsx b/client/src/components/Dashboard/Dashboard.jsx index 6d75cc550..764cc89a7 100644 --- a/client/src/components/Dashboard/Dashboard.jsx +++ b/client/src/components/Dashboard/Dashboard.jsx @@ -15,7 +15,7 @@ function Dashboard() { return (
} header="Dashboard" headerActions={ diff --git a/client/src/components/DataFiles/DataFiles.jsx b/client/src/components/DataFiles/DataFiles.jsx index 7810c1a84..2b09b3bbd 100644 --- a/client/src/components/DataFiles/DataFiles.jsx +++ b/client/src/components/DataFiles/DataFiles.jsx @@ -118,7 +118,7 @@ const DataFiles = () => { return (
{ return (
} diff --git a/client/src/components/ManageAccount/ManageAccount.jsx b/client/src/components/ManageAccount/ManageAccount.jsx index 5deb8b7bc..9a6d6bfb3 100644 --- a/client/src/components/ManageAccount/ManageAccount.jsx +++ b/client/src/components/ManageAccount/ManageAccount.jsx @@ -44,7 +44,7 @@ const ManageAccountView = () => { return (
{ config: { hideDataFiles: false }, }, notifications, - introMessages, + introMessageComponents, ticketCreate, })} > diff --git a/client/src/components/Onboarding/OnboardingUser.jsx b/client/src/components/Onboarding/OnboardingUser.jsx index 8e915364f..e8f92cdfc 100644 --- a/client/src/components/Onboarding/OnboardingUser.jsx +++ b/client/src/components/Onboarding/OnboardingUser.jsx @@ -45,7 +45,7 @@ const OnboardingUser = () => { return (
state.authenticatedUser.user ); - const introMessages = useSelector((state) => state.introMessages); + const introMessageComponents = useSelector( + (state) => state.introMessageComponents + ); return ( <> Add Ticket
diff --git a/client/src/components/Tickets/TicketStandaloneCreate.test.js b/client/src/components/Tickets/TicketStandaloneCreate.test.js index 826effacb..e752ab398 100644 --- a/client/src/components/Tickets/TicketStandaloneCreate.test.js +++ b/client/src/components/Tickets/TicketStandaloneCreate.test.js @@ -5,7 +5,7 @@ import renderComponent from 'utils/testing'; import TicketStandaloneCreate from './TicketStandaloneCreate'; import { initialTicketCreateState as ticketCreate } from '../../redux/reducers/tickets.reducers'; import { initialState as workbench } from '../../redux/reducers/workbench.reducers'; -import initialIntroMessages from '../../redux/reducers/intro.reducers'; +import initialIntroMessageComponents from '../../redux/reducers/portalMessages.reducers'; import { initialState as user } from '../../redux/reducers/authenticated_user.reducer'; const mockStore = configureStore(); @@ -15,7 +15,10 @@ describe('TicketStandaloneCreate', () => { const store = mockStore({ ticketCreate, authenticatedUser: user, - introMessages: initialIntroMessages, + introMessageComponents: { + ...initialIntroMessageComponents, + TICKETS: true, + }, workbench, }); @@ -29,7 +32,10 @@ describe('TicketStandaloneCreate', () => { const store = mockStore({ ticketCreate, authenticatedUser: user, - introMessages: { ...initialIntroMessages, TICKETS: false }, + introMessageComponents: { + ...initialIntroMessageComponents, + TICKETS: false, + }, workbench, }); diff --git a/client/src/components/UIPatterns/UIPatterns.jsx b/client/src/components/UIPatterns/UIPatterns.jsx index f16d357c3..e476a9b3d 100644 --- a/client/src/components/UIPatterns/UIPatterns.jsx +++ b/client/src/components/UIPatterns/UIPatterns.jsx @@ -14,7 +14,7 @@ import UIPatternsSidebar from './UIPatternsSidebar'; function UIPatterns() { return (
{ if (authenticatedUser) { dispatch({ type: 'FETCH_INTRO' }); + dispatch({ type: 'FETCH_CUSTOM_MESSAGES' }); } }, [authenticatedUser]); return ( diff --git a/client/src/components/Workbench/Workbench.test.js b/client/src/components/Workbench/Workbench.test.js index 2d766e263..498b2a576 100644 --- a/client/src/components/Workbench/Workbench.test.js +++ b/client/src/components/Workbench/Workbench.test.js @@ -14,7 +14,7 @@ import { } from '../../redux/reducers/tickets.reducers'; import { initialState as authenticatedUser } from '../../redux/reducers/authenticated_user.reducer'; import { initialState as systemMonitor } from '../../redux/reducers/systemMonitor.reducers'; -import { initialIntroMessages as introMessages } from '../../redux/reducers/intro.reducers'; +import { initialIntroMessageComponents as introMessageComponents } from '../../redux/reducers/portalMessages.reducers'; import { initialSystemState as systems } from '../../redux/reducers/datafiles.reducers'; import * as introMessageText from '../../constants/messages'; @@ -25,7 +25,7 @@ const state = { workbench, onboarding, notifications, - introMessages, + introMessageComponents, jobs, systemMonitor, ticketList, diff --git a/client/src/components/Workbench/index.test.js b/client/src/components/Workbench/index.test.js index dabcc5bf1..eb9c7d492 100644 --- a/client/src/components/Workbench/index.test.js +++ b/client/src/components/Workbench/index.test.js @@ -25,6 +25,7 @@ describe('AppRouter', () => { { type: 'FETCH_WORKBENCH' }, { type: 'FETCH_SYSTEMS' }, { type: 'FETCH_INTRO' }, + { type: 'FETCH_CUSTOM_MESSAGES' }, ]); }); }); diff --git a/client/src/components/_common/CustomMessage/CustomMessage.jsx b/client/src/components/_common/CustomMessage/CustomMessage.jsx new file mode 100644 index 000000000..99212df48 --- /dev/null +++ b/client/src/components/_common/CustomMessage/CustomMessage.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { SectionMessage } from '_common'; +import { useDispatch, useSelector } from 'react-redux'; +import styles from './CustomMessage.module.scss'; + +/** + * A message which, is created by the admin. Like IntroMessage, when dismissed, will not appear again. + * + * _This message is designed for custom messages from the admin. + * + * @example + * // message with identifier + * + * + */ +function CustomMessage({ messageComponentName }) { + const dispatch = useDispatch(); + const messages = useSelector((state) => { + return state.customMessages + ? state.customMessages.messages.filter((message) => { + return ( + message.unread && + message.template.component === messageComponentName + ); + }) + : []; + }); + + function onDismiss(dismissMessage) { + const payload = { + templateId: dismissMessage.template.id, + unread: false, + }; + dispatch({ + type: 'SAVE_CUSTOM_MESSAGES', + payload, + }); + } + + return messages.map((message) => { + const template = message.template; + return ( +
+ onDismiss(message)} + > + {template.message} + +
+ ); + }); +} + +CustomMessage.propTypes = { + /** A unique identifier for the message */ + messageComponentName: PropTypes.string.isRequired, +}; + +export default CustomMessage; diff --git a/client/src/components/_common/CustomMessage/CustomMessage.module.scss b/client/src/components/_common/CustomMessage/CustomMessage.module.scss new file mode 100644 index 000000000..2909c9e03 --- /dev/null +++ b/client/src/components/_common/CustomMessage/CustomMessage.module.scss @@ -0,0 +1,4 @@ +.message { + margin-top: 12px; + margin-bottom: 12px; +} diff --git a/client/src/components/_common/CustomMessage/CustomMessage.test.jsx b/client/src/components/_common/CustomMessage/CustomMessage.test.jsx new file mode 100644 index 000000000..947e47b18 --- /dev/null +++ b/client/src/components/_common/CustomMessage/CustomMessage.test.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import configureStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; + +import CustomMessage from './CustomMessage'; + +const mockStore = configureStore(); +const store = mockStore({ + customMessages: { + messages: [ + { + template: { + id: 1, + component: 'TEST', + message_type: 'warning', + dismissible: true, + message: 'Test Message', + }, + unread: true, + }, + ], + }, +}); + +describe('CustomMessage', () => { + describe('elements', () => { + it('renders message text, message type, and dismissability correctly', () => { + const { container, getByText } = render( + + + + ); + expect(container.getElementsByClassName('is-warn').length).toEqual(1); + expect(container.getElementsByClassName('close-button').length).toEqual( + 1 + ); + expect(getByText('Test Message')).not.toEqual(null); + }); + }); +}); diff --git a/client/src/components/_common/CustomMessage/index.js b/client/src/components/_common/CustomMessage/index.js new file mode 100644 index 000000000..abf551375 --- /dev/null +++ b/client/src/components/_common/CustomMessage/index.js @@ -0,0 +1 @@ +export { default } from './CustomMessage'; diff --git a/client/src/components/_common/IntroMessage/IntroMessage.jsx b/client/src/components/_common/IntroMessage/IntroMessage.jsx index ebd27d23f..18211ec78 100644 --- a/client/src/components/_common/IntroMessage/IntroMessage.jsx +++ b/client/src/components/_common/IntroMessage/IntroMessage.jsx @@ -8,39 +8,43 @@ import styles from './IntroMessage.module.css'; /** * Whether the name is of a known intro message - * @param {String} messageName - The name of the message to check + * @param {String} messageComponentName - The name of the component that contains the message */ -export function isKnownMessage(messageName) { - const introMessages = useSelector((state) => state.introMessages); +export function isKnownMessage(messageComponentName) { + const introMessageComponents = useSelector( + (state) => state.introMessageComponents + ); - return introMessages && introMessages[messageName]; + return introMessageComponents && introMessageComponents[messageComponentName]; } /** * A message which, when dismissed, will not appear again unless browser storage is cleared * - * _This message is designed for user introduction to sections, but can be abstracted further into a `` or abstracted less such that a message need not be passed in._ + * _This message is designed for user introduction to sections, but can be abstracted further into a `` or abstracted less such that a message need not be passed in._ * * @example * // message with custom text, class, and identifier * * Introductory text (defined externally). * */ -function IntroMessage({ children, className, messageName }) { +function IntroMessage({ children, className, messageComponentName }) { const dispatch = useDispatch(); - const introMessages = useSelector((state) => state.introMessages); - const shouldShow = isKnownMessage(messageName); + const introMessageComponents = useSelector( + (state) => state.introMessageComponents + ); + const shouldShow = isKnownMessage(messageComponentName); const [isVisible, setIsVisible] = useState(shouldShow); // Manage visibility const onDismiss = useCallback(() => { const newMessagesState = { - ...introMessages, - [messageName]: false, + ...introMessageComponents, + [messageComponentName]: false, }; dispatch({ type: 'SAVE_INTRO', payload: newMessagesState }); @@ -49,7 +53,7 @@ function IntroMessage({ children, className, messageName }) { return ( { it('includes class, message, and role appropriately', () => { const { container, getByRole, getByText } = render( - +

Test Message

diff --git a/client/src/components/_common/Section/Section.jsx b/client/src/components/_common/Section/Section.jsx index c390d1cb5..a6a6e5852 100644 --- a/client/src/components/_common/Section/Section.jsx +++ b/client/src/components/_common/Section/Section.jsx @@ -33,13 +33,13 @@ function getLayoutClass(contentLayoutName) { * @example * // manually build messages, automatically build intro message (by name) *
…} * /> * @example * // overwrite text of an automatic intro message, no additional messages *
* @example @@ -112,7 +112,7 @@ function Section({ // sidebarClassName, messages, messagesClassName, - introMessageName, + messageComponentName, introMessageText, }) { const shouldBuildHeader = header || headerClassName || headerActions; @@ -151,7 +151,7 @@ function Section({
{messages} @@ -229,11 +229,11 @@ Section.propTypes = { messages: PropTypes.node, /** Any additional className(s) for the message list */ messagesClassName: PropTypes.string, - /** The name of the intro message to use */ - introMessageName: PropTypes.string, + /** The name of the message to use */ + messageComponentName: PropTypes.string, /** Any additional className(s) for the sidebar list */ // sidebarClassName: '', - /** Custom intro text (can overwrite message from `introMessageName`) */ + /** Custom intro text (can overwrite message from `messageComponentName`) */ introMessageText: PropTypes.string, }; Section.defaultProps = { @@ -251,7 +251,7 @@ Section.defaultProps = { manualHeader: undefined, messages: '', messagesClassName: '', - introMessageName: '', + messageComponentName: '', // sidebarClassName: '', introMessageText: '', }; diff --git a/client/src/components/_common/Section/SectionMessages.jsx b/client/src/components/_common/Section/SectionMessages.jsx index d30241056..99af843dc 100644 --- a/client/src/components/_common/Section/SectionMessages.jsx +++ b/client/src/components/_common/Section/SectionMessages.jsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { IntroMessage, isKnownIntroMessage } from '_common'; +import { CustomMessage, IntroMessage, isKnownIntroMessage } from '_common'; import * as MESSAGES from '../../../constants/messages'; import styles from './SectionMessages.module.css'; @@ -17,11 +17,11 @@ import './SectionMessages.css'; * * @example * // an automatic intro message (if found), no additional messages - * + * * @example * // overwrite text of an automatic intro message, no additional messages * * @example * // define text for a manual intro message, no additional messages @@ -30,7 +30,7 @@ import './SectionMessages.css'; * /> * @example * // an automatic intro message (if found), some additional messages - * + * * You win! * * @@ -48,20 +48,28 @@ import './SectionMessages.css'; function SectionMessages({ children, className, - introMessageName, + messageComponentName, introMessageText, }) { - const introMessageContent = introMessageText || MESSAGES[introMessageName]; + const introMessageContent = + introMessageText || MESSAGES[messageComponentName]; const introMessage = introMessageContent && ( /* FAQ: Alternate message name allows tracking custom message dismissal */ - + {introMessageContent} ); const hasMessage = - isKnownIntroMessage(introMessageName) || children.length > 0; + isKnownIntroMessage(messageComponentName) || children.length > 0; const hasMessageClass = 'has-message'; + const customMessage = ( + + ); + useEffect(() => { if (hasMessage) { document.body.classList.add(hasMessageClass); @@ -73,24 +81,25 @@ function SectionMessages({ return ( ); } SectionMessages.propTypes = { - /** Component-based message(s) (e.g. , ) (intro message found automatically, given `introMessageName`) */ + /** Component-based message(s) (e.g. , ) (intro message found automatically, given `messageComponentName`) */ children: PropTypes.node, /** Any additional className(s) for the root element */ className: PropTypes.string, - /** The name of the route section (to search for required intro message) */ - introMessageName: PropTypes.string, - /** Custom intro text (can overwrite message from `introMessageName`) */ + /** The name of the route section (to search for required message) */ + messageComponentName: PropTypes.string, + /** Custom intro text (can overwrite message from `messageComponentName`) */ introMessageText: PropTypes.string, }; SectionMessages.defaultProps = { children: '', className: '', - introMessageName: '', + messageComponentName: '', introMessageText: '', }; diff --git a/client/src/components/_common/Section/SectionMessages.test.js b/client/src/components/_common/Section/SectionMessages.test.js index e8ca11e04..5e08f0b87 100644 --- a/client/src/components/_common/Section/SectionMessages.test.js +++ b/client/src/components/_common/Section/SectionMessages.test.js @@ -32,7 +32,7 @@ describe('SectionMessages', () => { it('renders known intro message', () => { const { getByText } = render( - + ); expect(getByText(MESSAGES['DASHBOARD'])).not.toEqual(null); @@ -42,7 +42,7 @@ describe('SectionMessages', () => { const { getByText, queryByText } = render( diff --git a/client/src/components/_common/index.js b/client/src/components/_common/index.js index 675c55918..265292a5d 100644 --- a/client/src/components/_common/index.js +++ b/client/src/components/_common/index.js @@ -24,6 +24,7 @@ export { default as IntroMessage, isKnownMessage as isKnownIntroMessage, } from './IntroMessage'; +export { default as CustomMessage } from './CustomMessage'; export { default as Pill } from './Pill'; export { default as TextCopyField } from './TextCopyField'; export { default as ShowMore } from './ShowMore'; diff --git a/client/src/redux/reducers/index.js b/client/src/redux/reducers/index.js index ac904f34c..886d295a4 100644 --- a/client/src/redux/reducers/index.js +++ b/client/src/redux/reducers/index.js @@ -16,7 +16,10 @@ import authenticatedUser from './authenticated_user.reducer'; import { pushKeys } from './systems.reducers'; import notifications from './notifications.reducers'; import workbench from './workbench.reducers'; -import introMessages from './intro.reducers'; +import { + introMessageComponents, + customMessages, +} from './portalMessages.reducers'; import { onboarding } from './onboarding.reducers'; import projects from './projects.reducers'; import { users } from './users.reducers'; @@ -41,7 +44,8 @@ export default combineReducers({ pushKeys, notifications, workbench, - introMessages, + introMessageComponents, + customMessages, onboarding, projects, users, diff --git a/client/src/redux/reducers/intro.reducers.js b/client/src/redux/reducers/intro.reducers.js deleted file mode 100644 index 3e3d0a579..000000000 --- a/client/src/redux/reducers/intro.reducers.js +++ /dev/null @@ -1,45 +0,0 @@ -export const initialIntroMessages = { - DASHBOARD: true, - APPLICATIONS: true, - DATA: true, - ALLOCATIONS: true, - HISTORY: true, - ACCOUNT: true, - TICKETS: true, -}; - -function introMessages(state = initialIntroMessages, action) { - switch (action.type) { - case 'INTRO_FETCH_STARTED': - return { - ...state, - }; - case 'INTRO_FETCH_SUCCESS': - return { - ...state, - ...action.payload, - }; - case 'INTRO_FETCH_ERROR': - return { - ...state, - }; - case 'INTRO_SAVE_STARTED': - return { - ...state, - }; - case 'INTRO_SAVE_SUCCESS': - return { - ...state, - ...action.payload, - }; - case 'INTRO_SAVE_ERROR': - return { - ...state, - ...action.paylod, - }; - default: - return state; - } -} - -export default introMessages; diff --git a/client/src/redux/reducers/portalMessages.reducers.js b/client/src/redux/reducers/portalMessages.reducers.js new file mode 100644 index 000000000..7d568eab8 --- /dev/null +++ b/client/src/redux/reducers/portalMessages.reducers.js @@ -0,0 +1,94 @@ +function updateCustomMessages(state, payload) { + return { + messages: state.messages.map((message) => { + message.unread = + message.template.id === payload.templateId + ? payload.unread + : message.unread; + return message; + }), + }; +} + +export const initialIntroMessageComponents = { + DASHBOARD: true, + APPLICATIONS: true, + DATA: true, + ALLOCATIONS: true, + HISTORY: true, + ACCOUNT: true, + TICKETS: true, +}; + +export const initialCustomMessages = { + messages: [], +}; + +export function introMessageComponents( + state = initialIntroMessageComponents, + action +) { + switch (action.type) { + case 'INTRO_FETCH_STARTED': + return { + ...state, + }; + case 'INTRO_FETCH_SUCCESS': + return { + ...state, + ...action.payload, + }; + case 'INTRO_FETCH_ERROR': + return { + ...state, + }; + case 'INTRO_SAVE_STARTED': + return { + ...state, + }; + case 'INTRO_SAVE_SUCCESS': + return { + ...state, + ...action.payload, + }; + case 'INTRO_SAVE_ERROR': + return { + ...action.payload, + }; + default: + return state; + } +} + +export function customMessages(state = initialCustomMessages, action) { + switch (action.type) { + case 'CUSTOM_MESSAGES_FETCH_STARTED': + return { + ...state, + }; + case 'CUSTOM_MESSAGES_FETCH_SUCCESS': + return { + ...state, + ...action.payload, + }; + case 'CUSTOM_MESSAGES_FETCH_ERROR': + return { + ...state, + }; + case 'CUSTOM_MESSAGES_SAVE_STARTED': + return { + ...state, + }; + case 'CUSTOM_MESSAGES_SAVE_SUCCESS': + return { + ...state, + ...updateCustomMessages(state, action.payload), + }; + case 'CUSTOM_MESSAGES_SAVE_ERROR': + return { + ...action.payload, + }; + default: + return state; + } +} diff --git a/client/src/redux/sagas/index.js b/client/src/redux/sagas/index.js index 6071efdf8..c93b968b6 100644 --- a/client/src/redux/sagas/index.js +++ b/client/src/redux/sagas/index.js @@ -39,7 +39,12 @@ import { import { watchPostRequestAccess } from './requestAccess.sagas'; import { watchAuthenticatedUser } from './authenticated_user.sagas'; import { watchWorkbench } from './workbench.sagas'; -import { watchFetchIntroMessages, watchSaveIntroMessages } from './intro.sagas'; +import { + watchFetchIntroMessageComponents, + watchSaveIntroMessageComponents, + watchFetchCustomMessages, + watchSaveCustomMessages, +} from './portalMessages.sagas'; import { watchOnboardingAdminList, watchOnboardingAdminIndividualUser, @@ -89,8 +94,10 @@ export default function* rootSaga() { watchSocket(), watchFetchNotifications(), watchWorkbench(), - watchFetchIntroMessages(), - watchSaveIntroMessages(), + watchFetchIntroMessageComponents(), + watchFetchCustomMessages(), + watchSaveIntroMessageComponents(), + watchSaveCustomMessages(), watchOnboardingAdminList(), watchOnboardingAdminIndividualUser(), watchOnboardingAction(), diff --git a/client/src/redux/sagas/intro.sagas.js b/client/src/redux/sagas/intro.sagas.js deleted file mode 100644 index e5b8b5ac5..000000000 --- a/client/src/redux/sagas/intro.sagas.js +++ /dev/null @@ -1,74 +0,0 @@ -import { put, takeLatest, takeLeading, call } from 'redux-saga/effects'; -import { fetchUtil } from 'utils/fetchUtil'; - -export async function getIntroMessages() { - const result = await fetchUtil({ - url: `/api/intromessages/`, - method: 'get', - }); - return result.response; -} - -export function* fetchIntroMessages() { - yield put({ type: 'INTRO_FETCH_STARTED' }); - try { - const introMessages = yield call(getIntroMessages); - // create complete list of IntroMessages for the user with status - // for all messages set to unread (true) - const messages = { - ACCOUNT: true, - ALLOCATIONS: true, - APPLICATIONS: true, - DASHBOARD: true, - DATA: true, - HISTORY: true, - TICKETS: true, - UI: true, - }; - - introMessages.forEach((element) => { - messages[element.component] = element.unread; - }); - - yield put({ - type: 'INTRO_FETCH_SUCCESS', - payload: messages, - }); - } catch (error) { - yield put({ - type: 'INTRO_FETCH_ERROR', - }); - } -} - -export function* watchFetchIntroMessages() { - yield takeLeading('FETCH_INTRO', fetchIntroMessages); -} - -// Write IntroMessages to the database and update the redux store. -export function* saveIntroMessages(action) { - yield put({ type: 'INTRO_SAVE_STARTED' }); - try { - yield call(fetchUtil, { - url: '/api/intromessages/', - method: 'PUT', - body: JSON.stringify(action.payload), - }); - - yield put({ - type: 'INTRO_SAVE_SUCCESS', - payload: action.payload, - }); - } catch (error) { - // Return the intended state of intro messages - // regardless of save success or failure - yield put({ - type: 'INTRO_SAVE_ERROR', - payload: action.payload, - }); - } -} - -export function* watchSaveIntroMessages() { - yield takeLatest('SAVE_INTRO', saveIntroMessages); -} diff --git a/client/src/redux/sagas/portalMessages.sagas.js b/client/src/redux/sagas/portalMessages.sagas.js new file mode 100644 index 000000000..ab48921cd --- /dev/null +++ b/client/src/redux/sagas/portalMessages.sagas.js @@ -0,0 +1,127 @@ +import { put, takeLatest, takeLeading, call } from 'redux-saga/effects'; +import { fetchUtil } from 'utils/fetchUtil'; + +export async function getIntroMessageComponents() { + const result = await fetchUtil({ + url: `/api/portal_messages/intro/`, + method: 'get', + }); + return result.response; +} + +export function* fetchIntroMessageComponents() { + yield put({ type: 'INTRO_FETCH_STARTED' }); + try { + const introMessageComponents = yield call(getIntroMessageComponents); + // create complete list of IntroMessageComponents for the user with status + // for all messages set to unread (true) + const messageComponents = { + ACCOUNT: true, + ALLOCATIONS: true, + APPLICATIONS: true, + DASHBOARD: true, + DATA: true, + HISTORY: true, + TICKETS: true, + UI: true, + }; + + introMessageComponents.forEach((element) => { + messageComponents[element.component] = element.unread; + }); + + yield put({ + type: 'INTRO_FETCH_SUCCESS', + payload: messageComponents, + }); + } catch (error) { + yield put({ + type: 'INTRO_FETCH_ERROR', + }); + } +} + +export function* watchFetchIntroMessageComponents() { + yield takeLeading('FETCH_INTRO', fetchIntroMessageComponents); +} + +// Write IntroMessageComponents to the database and update the redux store. +export function* saveIntroMessageComponents(action) { + yield put({ type: 'INTRO_SAVE_STARTED' }); + try { + yield call(fetchUtil, { + url: '/api/portal_messages/intro/', + method: 'PUT', + body: JSON.stringify(action.payload), + }); + + yield put({ + type: 'INTRO_SAVE_SUCCESS', + payload: action.payload, + }); + } catch (error) { + // Return the intended state of intro message components + // regardless of save success or failure + yield put({ + type: 'INTRO_SAVE_ERROR', + payload: action.payload, + }); + } +} + +export function* watchSaveIntroMessageComponents() { + yield takeLatest('SAVE_INTRO', saveIntroMessageComponents); +} + +export async function getCustomMessages() { + const result = await fetchUtil({ + url: `/api/portal_messages/custom/`, + method: 'get', + }); + return result.response; +} + +export function* fetchCustomMessages() { + yield put({ type: 'CUSTOM_MESSAGES_FETCH_STARTED' }); + try { + const customMessages = yield call(getCustomMessages); + + yield put({ + type: 'CUSTOM_MESSAGES_FETCH_SUCCESS', + payload: customMessages, + }); + } catch (error) { + yield put({ + type: 'CUSTOM_MESSAGES_FETCH_ERROR', + }); + } +} + +export function* watchFetchCustomMessages() { + yield takeLeading('FETCH_CUSTOM_MESSAGES', fetchCustomMessages); +} + +export function* saveCustomMessages(action) { + yield put({ type: 'CUSTOM_MESSAGES_SAVE_STARTED' }); + try { + yield call(fetchUtil, { + url: '/api/portal_messages/custom/', + method: 'PUT', + body: JSON.stringify(action.payload), + }); + + yield put({ + type: 'CUSTOM_MESSAGES_SAVE_SUCCESS', + payload: action.payload, + }); + } catch (error) { + yield put({ + type: 'CUSTOM_MESSAGES_SAVE_ERROR', + payload: action.payload, + }); + } +} + +export function* watchSaveCustomMessages() { + yield takeLatest('SAVE_CUSTOM_MESSAGES', saveCustomMessages); +} diff --git a/server/portal/apps/intromessages/admin.py b/server/portal/apps/intromessages/admin.py deleted file mode 100644 index 4185d360e..000000000 --- a/server/portal/apps/intromessages/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.contrib import admin - -# Register your models here. diff --git a/server/portal/apps/intromessages/apps.py b/server/portal/apps/intromessages/apps.py deleted file mode 100644 index 14ca66b24..000000000 --- a/server/portal/apps/intromessages/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class IntromessagesConfig(AppConfig): - name = 'intromessages' - app_label = 'intromessages' diff --git a/server/portal/apps/intromessages/intro_unit_test.py b/server/portal/apps/intromessages/intro_unit_test.py deleted file mode 100644 index be9bf579f..000000000 --- a/server/portal/apps/intromessages/intro_unit_test.py +++ /dev/null @@ -1,66 +0,0 @@ -import pytest -from portal.apps.intromessages.models import IntroMessages - - -@pytest.fixture -def intromessage_mock(authenticated_user): - IntroMessages.objects.create(user=authenticated_user, component="HISTORY", unread=False) - - -""" -Test get of "read" (not unread) IntroMessages for an authenticated user and -confirm that the JSON is coming back as expected. -""" - - -@pytest.mark.django_db(transaction=True, reset_sequences=True) -def test_intromessages_get(client, authenticated_user, intromessage_mock): - response = client.get('/api/intromessages/') - data = response.json() - assert response.status_code == 200 - print(data) - assert data["response"] == [{"component": "HISTORY", "unread": False}] - - -""" -Test get of "read" IntroMessages for an unauthenticated user -User should be redirected to login -""" - - -@pytest.mark.django_db(transaction=True, reset_sequences=True) -def test_intromessages_get_unauthenticated_user(client, regular_user): - response = client.get('/api/intromessages/') - assert response.status_code == 302 - - -"""Test the marking of an IntroMessage as "read" by writing to the database """ - - -@pytest.mark.django_db(transaction=True, reset_sequences=True) -def test_intromessages_put(client, authenticated_user): - body = { - 'ACCOUNT': 'True', - 'ALLOCATIONS': 'True', - 'APPLICATIONS': 'True', - 'DASHBOARD': 'True', - 'DATA': 'True', - 'HISTORY': 'False', - 'TICKETS': 'True', - 'UI': 'True' - } - - response = client.put('/api/intromessages/', - content_type="application/json", - data=body) - assert response.status_code == 200 - # should be eight rows in the database for the user - assert len(IntroMessages.objects.all()) == 8 - # let's check to see all rows exist correctly - for component_name, component_value in body.items(): - correct_status = False - db_message = IntroMessages.objects.filter(component=component_name) - if db_message and db_message[0].unread != component_value: - correct_status = True - - assert correct_status diff --git a/server/portal/apps/intromessages/models.py b/server/portal/apps/intromessages/models.py deleted file mode 100644 index a324adf7a..000000000 --- a/server/portal/apps/intromessages/models.py +++ /dev/null @@ -1,30 +0,0 @@ -import logging -from django.db import models -from django.conf import settings -from django.utils import timezone - -logger = logging.getLogger(__name__) - - -class IntroMessages(models.Model): - """IntroMessages - - Used for storing the visited status of each of the Intro (formerly Welcome) messages. - """ - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - related_name="+", - on_delete=models.CASCADE - ) - - datetime = models.DateTimeField(default=timezone.now, blank=True) - - # Each variable represents that intro message status - # True means message has not been dismissed by user - component = models.CharField(max_length=300, default='') - unread = models.BooleanField(default=True) - - # Make each type of IntroMessage unique - class Meta: - unique_together = ('user', 'component',) diff --git a/server/portal/apps/intromessages/urls.py b/server/portal/apps/intromessages/urls.py deleted file mode 100644 index 099c61af8..000000000 --- a/server/portal/apps/intromessages/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -"""IntroMessages URLs -""" -from django.urls import path -from portal.apps.intromessages import views - - -app_name = 'intromessages' -urlpatterns = [ - path('', views.IntroMessagesView.as_view()), -] diff --git a/server/portal/apps/portal_messages/admin.py b/server/portal/apps/portal_messages/admin.py new file mode 100644 index 000000000..e1c1bba91 --- /dev/null +++ b/server/portal/apps/portal_messages/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from portal.apps.portal_messages.models import CustomMessageTemplate + + +@admin.register(CustomMessageTemplate) +class CustomMessageTemplateAdmin(admin.ModelAdmin): + fields = ('message_type', 'component', 'message', 'dismissible') diff --git a/server/portal/apps/portal_messages/apps.py b/server/portal/apps/portal_messages/apps.py new file mode 100644 index 000000000..35acc879b --- /dev/null +++ b/server/portal/apps/portal_messages/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PortalMessagesConfig(AppConfig): + name = 'portal_message' + app_label = 'portal_message' diff --git a/server/portal/apps/portal_messages/intro_unit_test.py b/server/portal/apps/portal_messages/intro_unit_test.py new file mode 100644 index 000000000..c6b007c05 --- /dev/null +++ b/server/portal/apps/portal_messages/intro_unit_test.py @@ -0,0 +1,140 @@ +import pytest +from portal.apps.portal_messages.models import IntroMessages, CustomMessageTemplate, CustomMessages + + +@pytest.fixture +def intromessage_mock(authenticated_user): + IntroMessages.objects.create(user=authenticated_user, component="HISTORY", unread=False) + + +""" +Test get of "read" (not unread) IntroMessages for an authenticated user and +confirm that the JSON is coming back as expected. +""" + + +@pytest.mark.django_db(transaction=True, reset_sequences=True) +def test_intromessages_get(client, authenticated_user, intromessage_mock): + response = client.get('/api/portal_messages/intro/') + data = response.json() + assert response.status_code == 200 + print(data) + assert data["response"] == [{"component": "HISTORY", "unread": False}] + + +""" +Test get of "read" IntroMessages for an unauthenticated user +User should be redirected to login +""" + + +@pytest.mark.django_db(transaction=True, reset_sequences=True) +def test_intromessages_get_unauthenticated_user(client, regular_user): + response = client.get('/api/portal_messages/intro/') + assert response.status_code == 302 + + +"""Test the marking of an IntroMessage as "read" by writing to the database """ + + +@pytest.mark.django_db(transaction=True, reset_sequences=True) +def test_intromessages_put(client, authenticated_user): + body = { + 'ACCOUNT': 'True', + 'ALLOCATIONS': 'True', + 'APPLICATIONS': 'True', + 'DASHBOARD': 'True', + 'DATA': 'True', + 'HISTORY': 'False', + 'TICKETS': 'True', + 'UI': 'True' + } + + response = client.put('/api/portal_messages/intro/', + content_type="application/json", + data=body) + assert response.status_code == 200 + # should be eight rows in the database for the user + assert len(IntroMessages.objects.all()) == 8 + # let's check to see all rows exist correctly + for component_name, component_value in body.items(): + correct_status = False + db_message = IntroMessages.objects.filter(component=component_name) + if db_message and db_message[0].unread != component_value: + correct_status = True + + assert correct_status + + +@pytest.fixture +def custommessagetemplate_mock(): + template = CustomMessageTemplate.objects.create(component='HISTORY', message_type='warning', message='test message', dismissible=True) + yield template + + +@pytest.fixture +def custommessage_mock(authenticated_user, custommessagetemplate_mock): + message = CustomMessages.objects.create(user=authenticated_user, template=custommessagetemplate_mock) + yield message + + +""" +Test get of "read" (not unread) CustomMessages for an authenticated user and +confirm that the JSON is coming back as expected. +""" + + +@pytest.mark.django_db(transaction=True, reset_sequences=True) +def test_custommessages_get(client, authenticated_user, custommessage_mock, custommessagetemplate_mock): + response = client.get('/api/portal_messages/custom/') + data = response.json() + assert response.status_code == 200 + assert data["response"] == { + 'messages': [{ + "template": { + 'id': 1, + 'component': 'HISTORY', + 'message_type': 'warning', + 'dismissible': True, + 'message': 'test message' + }, + "unread": True + }] + } + + +""" +Test get of "read" CustomMessages for an unauthenticated user +User should be redirected to login +""" + + +@pytest.mark.django_db(transaction=True, reset_sequences=True) +def test_custommessages_get_unauthenticated_user(client, regular_user): + response = client.get('/api/portal_messages/custom/') + assert response.status_code == 302 + + +"""Test the marking of an CustomMessage as "read" by writing to the database """ + + +@pytest.mark.django_db(transaction=True, reset_sequences=True) +def test_custommessages_put(client, authenticated_user, custommessage_mock, custommessagetemplate_mock): + original_message = CustomMessages.objects.get(template__id='1') + assert original_message.unread is True + + body = { + 'templateId': 1, + 'unread': False + } + + response = client.put('/api/portal_messages/custom/', + content_type="application/json", + data=body) + assert response.status_code == 200 + + assert len(CustomMessages.objects.all()) == 1 + + db_message = CustomMessages.objects.get(template__id=body['templateId']) + # Ensure that it updated the value correctly + assert db_message.unread == body['unread'] diff --git a/server/portal/apps/intromessages/migrations/0001_initial.py b/server/portal/apps/portal_messages/migrations/0001_initial.py similarity index 100% rename from server/portal/apps/intromessages/migrations/0001_initial.py rename to server/portal/apps/portal_messages/migrations/0001_initial.py diff --git a/server/portal/apps/portal_messages/migrations/0002_custommessages_custommessagetemplate.py b/server/portal/apps/portal_messages/migrations/0002_custommessages_custommessagetemplate.py new file mode 100644 index 000000000..4367f3f0a --- /dev/null +++ b/server/portal/apps/portal_messages/migrations/0002_custommessages_custommessagetemplate.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.27 on 2022-03-22 15:48 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal_messages', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CustomMessageTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('component', models.CharField(choices=[('Dashboard', 'DASHBOARD'), ('Data Files', 'DATA'), ('Applications', 'APPLICATIONS'), ('Allocations', 'ALLOCATIONS'), ('History', 'HISTORY'), ('Account', 'ACCOUNT')], default='Dashboard', help_text='Component type', max_length=20)), + ('message_type', models.CharField(choices=[('info', 'Info'), ('success', 'Success'), ('warning', 'Warn'), ('error', 'Error')], default='info', help_text='Message type', max_length=20)), + ('dismissible', models.BooleanField(default=False)), + ('message', models.CharField(blank=True, max_length=200, default='', help_text='Message content (max 200 characters)')), + ], + ), + + migrations.CreateModel( + name='CustomMessages', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='portal_messages.CustomMessageTemplate')), + ('unread', models.BooleanField(default=True)), + ], + options={ + 'unique_together': {('user', 'template')}, + }, + ), + ] diff --git a/server/portal/apps/intromessages/migrations/__init__.py b/server/portal/apps/portal_messages/migrations/__init__.py similarity index 100% rename from server/portal/apps/intromessages/migrations/__init__.py rename to server/portal/apps/portal_messages/migrations/__init__.py diff --git a/server/portal/apps/portal_messages/models.py b/server/portal/apps/portal_messages/models.py new file mode 100644 index 000000000..25464d217 --- /dev/null +++ b/server/portal/apps/portal_messages/models.py @@ -0,0 +1,85 @@ +import logging +from django.db import models +from django.conf import settings +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +class IntroMessages(models.Model): + """IntroMessages + + Used for storing the visited status of each of the Intro (formerly Welcome) messages. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="+", + on_delete=models.CASCADE + ) + + datetime = models.DateTimeField(default=timezone.now, blank=True) + + # Each variable represents that intro message status + # True means message has not been dismissed by user + component = models.CharField(max_length=300, default='') + unread = models.BooleanField(default=True) + + # Make each type of IntroMessage unique + class Meta: + unique_together = ('user', 'component',) + + +class CustomMessageTemplate(models.Model): + """CustomMessageTemplate + + Used for storing admin-controlled messages for specific components that utilize CustomMessages. + """ + + MESSAGE_TYPES = [('info', 'Info'), ('success', 'Success'), + ('warning', 'Warn'), ('error', 'Error')] + + COMPONENTS = [('DASHBOARD', 'Dashboard'), ('DATA', 'Data Files'), + ('APPLICATIONS', 'Applications'), ('ALLOCATIONS', 'Allocations'), + ('HISTORY', 'History'), ('UI', 'UI'), ('ACCOUNT', 'Account')] + + component = models.CharField(help_text='Component type', max_length=20, choices=COMPONENTS, default='Dashboard') + message_type = models.CharField(help_text='Message type', max_length=20, choices=MESSAGE_TYPES, default='info') + dismissible = models.BooleanField(default=False) + message = models.CharField(help_text='Message content (max 200 characters)', max_length=200, default='', blank=True) + + def to_dict(self): + return { + 'id': self.id, + 'component': self.component, + 'message_type': self.message_type, + 'dismissible': self.dismissible, + 'message': self.message + } + + def __str__(self): + return "%s | %s | %s | %s" % (self.message_type, self.component, + ('dismissible' if self.dismissible else 'not dismissible'), self.message[0:20]) + + +class CustomMessages(models.Model): + """CustomMessages + + Used for storing messages instances that were created by admin and handles status of each message. + """ + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="+", + on_delete=models.CASCADE + ) + + template = models.ForeignKey( + CustomMessageTemplate, + related_name="+", + on_delete=models.CASCADE, + ) + + unread = models.BooleanField(default=True) + + class Meta: + unique_together = ('user', 'template',) diff --git a/server/portal/apps/portal_messages/urls.py b/server/portal/apps/portal_messages/urls.py new file mode 100644 index 000000000..f0f446a34 --- /dev/null +++ b/server/portal/apps/portal_messages/urls.py @@ -0,0 +1,11 @@ +"""Message URLs +""" +from django.urls import path +from portal.apps.portal_messages import views + + +app_name = 'message' +urlpatterns = [ + path('intro/', views.IntroMessagesView.as_view()), + path('custom/', views.CustomMessagesView.as_view()) +] diff --git a/server/portal/apps/intromessages/views.py b/server/portal/apps/portal_messages/views.py similarity index 51% rename from server/portal/apps/intromessages/views.py rename to server/portal/apps/portal_messages/views.py index bdeaef8df..d37625908 100644 --- a/server/portal/apps/intromessages/views.py +++ b/server/portal/apps/portal_messages/views.py @@ -1,12 +1,13 @@ """ -.. :module: apps.intromessages.views +.. :module: apps.portal_messages.views :synopsis: Views to handle read/unread status of IntroMessages + and generate CustomMessages """ import logging from portal.views.base import BaseApiView from django.http import JsonResponse -from portal.apps.intromessages.models import IntroMessages +from portal.apps.portal_messages.models import IntroMessages, CustomMessages, CustomMessageTemplate from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator import json @@ -15,6 +16,18 @@ logger = logging.getLogger(__name__) +def get_or_create_custom_messages(user, template): + try: + message = CustomMessages.objects.get(user=user, template=template) + except CustomMessages.DoesNotExist: + message = CustomMessages.objects.create(user=user, template=template) + + return { + 'template': message.template.to_dict(), + 'unread': message.unread, + } + + @method_decorator(login_required, name='dispatch') class IntroMessagesView(BaseApiView): def get(self, request, *args, **kwargs): @@ -34,3 +47,25 @@ def put(self, request, *args): new_db_message = IntroMessages.objects.create(user=request.user, component=component_name, unread=component_value) new_db_message.save() return JsonResponse({'status': 'OK'}) + + +@method_decorator(login_required, name='dispatch') +class CustomMessagesView(BaseApiView): + def get(self, request, *args, **kwargs): + templates = CustomMessageTemplate.objects.all() + messages = [get_or_create_custom_messages(request.user, template) for template in templates] + + return JsonResponse({ + 'response': { + 'messages': list(messages), + } + }) + + def put(self, request, *args): + body = json.loads(request.body) + template_id = body['templateId'] + unread = body['unread'] + message = CustomMessages.objects.get(user=request.user, template__id=template_id) + message.unread = unread + message.save() + return JsonResponse({'status': 'OK'}) diff --git a/server/portal/settings/settings.py b/server/portal/settings/settings.py index 4c52931a5..dae88229d 100644 --- a/server/portal/settings/settings.py +++ b/server/portal/settings/settings.py @@ -99,7 +99,7 @@ def gettext(s): return s # noqa:E731 'portal.apps.request_access', 'portal.apps.site_search', 'portal.apps.jupyter_mounts', - 'portal.apps.intromessages', + 'portal.apps.portal_messages', ] MIDDLEWARE = [ diff --git a/server/portal/settings/unit_test_settings.py b/server/portal/settings/unit_test_settings.py index 7bb138afc..5edba3aa9 100644 --- a/server/portal/settings/unit_test_settings.py +++ b/server/portal/settings/unit_test_settings.py @@ -85,7 +85,7 @@ def gettext(s): return s 'portal.apps.googledrive_integration', 'portal.apps.datafiles', 'portal.apps.projects', - 'portal.apps.intromessages', + 'portal.apps.portal_messages', ] diff --git a/server/portal/urls.py b/server/portal/urls.py index 7dc7ad041..065982455 100644 --- a/server/portal/urls.py +++ b/server/portal/urls.py @@ -77,8 +77,8 @@ path('request-access/', include('portal.apps.request_access.urls', namespace='request_access')), path('search/', include('portal.apps.site_search.urls', namespace='site_search')), - # intromessages - path('api/intromessages/', include('portal.apps.intromessages.urls', namespace='intromessages')), + # portal_messages + path('api/portal_messages/', include('portal.apps.portal_messages.urls', namespace='portal_messages')), # integrations