diff --git a/frontend/src/js/actions/userActions.js b/frontend/src/js/actions/userActions.js index 9a2e4a90..e5ed2262 100644 --- a/frontend/src/js/actions/userActions.js +++ b/frontend/src/js/actions/userActions.js @@ -29,6 +29,7 @@ import { duplicateFilter, extractErrorMessage, isEmpty, preformatWithRequestID } import { getCurrentUser, getOnboardingState, getOrganization, getTooltipsState, getUserSettings as getUserSettingsSelector } from '../selectors'; import { clearAllRetryTimers } from '../utils/retrytimer'; import { commonErrorFallback, commonErrorHandler, initializeAppData, setOfflineThreshold, setSnackbar } from './appActions'; +import { tenantadmApiUrlv2 } from './organizationActions'; const cookies = new Cookies(); const { @@ -797,3 +798,18 @@ export const setAllTooltipsReadState = const updatedTips = Object.keys(HELPTOOLTIPS).reduce((accu, id) => ({ ...accu, [id]: { readState } }), {}); return Promise.resolve(dispatch({ type: UserConstants.SET_TOOLTIPS_STATE, value: updatedTips })).then(() => dispatch(saveUserSettings())); }; + +export const submitFeedback = + ({ satisfaction, feedback, ...additionalInfo }) => + (dispatch, getState) => { + const meta = { + ...additionalInfo, + tenant: getOrganization(getState()) + }; + return GeneralApi.post(`${tenantadmApiUrlv2}/contact/support`, { feedback, satisfaction, meta }).then(() => { + const today = new Date(); + dispatch(saveUserSettings({ feedbackCollected: today.toISOString().substring(0, 7) })); + setTimeout(() => dispatch(dismissFeedbackDialog()), AppConstants.TIMEOUTS.threeSeconds); + }); + }; +export const dismissFeedbackDialog = () => dispatch => Promise.resolve(dispatch({ type: UserConstants.SET_SHOW_FEEDBACK_DIALOG, value: false })); diff --git a/frontend/src/js/components/app.js b/frontend/src/js/components/app.js index 52f519c2..4079d7f2 100644 --- a/frontend/src/js/components/app.js +++ b/frontend/src/js/components/app.js @@ -39,6 +39,7 @@ import { dark as darkTheme, light as lightTheme } from '../themes/Mender'; import Tracking from '../tracking'; import ConfirmDismissHelptips from './common/dialogs/confirmdismisshelptips'; import DeviceConnectionDialog from './common/dialogs/deviceconnectiondialog'; +import FeedbackDialog from './common/dialogs/feedback'; import StartupNotificationDialog from './common/dialogs/startupnotification'; import Footer from './footer'; import Header from './header/header'; @@ -103,6 +104,7 @@ export const AppRoot = () => { const showDismissHelptipsDialog = useSelector(state => !state.onboarding.complete && state.onboarding.showTipsDialog); const showDeviceConnectionDialog = useSelector(state => state.users.showConnectDeviceDialog); const showStartupNotification = useSelector(state => state.users.showStartupNotification); + const showFeedbackDialog = useSelector(state => state.users.showFeedbackDialog); const snackbar = useSelector(state => state.app.snackbar); const trackingCode = useSelector(state => state.app.trackerCode); const { mode } = useSelector(getUserSettings); @@ -203,6 +205,7 @@ export const AppRoot = () => { {showDismissHelptipsDialog && } {showDeviceConnectionDialog && dispatch(setShowConnectingDialog(false))} />} {showStartupNotification && } + {showFeedbackDialog && } ) : (
diff --git a/frontend/src/js/components/common/dialogs/feedback.js b/frontend/src/js/components/common/dialogs/feedback.js new file mode 100644 index 00000000..9cf51127 --- /dev/null +++ b/frontend/src/js/components/common/dialogs/feedback.js @@ -0,0 +1,165 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + Close as CloseIcon, + SentimentVeryDissatisfied as DissatisfiedIcon, + SentimentNeutral as NeutralIcon, + SentimentSatisfiedAlt as SatisfiedIcon, + SentimentVeryDissatisfiedOutlined as VeryDissatisfiedIcon, + SentimentVerySatisfiedOutlined as VerySatisfiedIcon +} from '@mui/icons-material'; +import { + Button, + Dialog, + DialogContent, + DialogTitle, + IconButton, + TextField, + dialogClasses, + dialogTitleClasses, + iconButtonClasses, + lighten, + textFieldClasses +} from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; + +import { dismissFeedbackDialog, submitFeedback } from '../../../actions/userActions'; +import { TIMEOUTS } from '../../../constants/appConstants'; + +const useStyles = makeStyles()(theme => ({ + root: { + [`.${dialogClasses.paper}`]: { width: 350, bottom: 0, right: 0, position: 'absolute' }, + [`.${dialogTitleClasses.root}`]: { + alignSelf: 'flex-end', + padding: 0, + [`.${iconButtonClasses.root}`]: { paddingBottom: 0 } + } + }, + columns: { gap: theme.spacing(2) }, + rating: { + [`.${iconButtonClasses.root}`]: { + borderRadius: theme.shape.borderRadius, + height: theme.spacing(6), + width: theme.spacing(6), + backgroundColor: lighten(theme.palette.primary.main, 0.85), + color: theme.palette.primary.main, + '&:hover': { + backgroundColor: theme.palette.primary.main, + color: lighten(theme.palette.primary.main, 0.85) + } + } + }, + text: { [`.${textFieldClasses.root}`]: { marginTop: 0 }, '.submitButton': { alignSelf: 'start' } } +})); + +const satisfactionLevels = [ + { Icon: VeryDissatisfiedIcon, title: 'Very Dissatisfied' }, + { Icon: DissatisfiedIcon, title: 'Dissatisfied' }, + { Icon: NeutralIcon, title: 'Neutral' }, + { Icon: SatisfiedIcon, title: 'Satisfied' }, + { Icon: VerySatisfiedIcon, title: 'Very Satisfied' } +]; +const explanations = ['Very unsatisfied', 'Very satisfied']; + +const SatisfactionGauge = ({ classes, setSatisfaction }) => { + return ( +
+
How satisfied are you with Mender?
+
+ {satisfactionLevels.map(({ Icon, title }, index) => ( + setSatisfaction(index)} title={title}> + + + ))} +
+
+ {explanations.map((explanation, index) => ( +
+ {explanation} +
+ ))} +
+
+ ); +}; + +const TextEntry = ({ classes, feedback, onChangeFeedback, onSubmit }) => ( +
+
What do you think is the most important thing to improve in Mender? (optional)
+ onChangeFeedback(value)} value={feedback} variant="outlined" /> + +
+); + +const AppreciationNote = () =>

Thank you for taking the time to share your thoughts!

; + +const progressionLevels = [SatisfactionGauge, TextEntry, AppreciationNote]; + +export const FeedbackDialog = () => { + const [progress, setProgress] = useState(0); + const [satisfaction, setSatisfaction] = useState(2); + const [feedback, setFeedback] = useState(''); + const dispatch = useDispatch(); + const isInitialized = useRef(false); + + const { classes } = useStyles(); + + useEffect(() => { + if (!isInitialized.current) { + return; + } + setProgress(current => current + 1); + }, [satisfaction]); + + useEffect(() => { + setTimeout(() => (isInitialized.current = true), TIMEOUTS.threeSeconds); + }, []); + + const onCloseClick = () => dispatch(dismissFeedbackDialog()); + + const onAdvance = () => setProgress(current => current + 1); + + const onSubmit = () => { + onAdvance(); + dispatch(submitFeedback({ satisfaction: satisfactionLevels[satisfaction].title, feedback })); + }; + + const Component = progressionLevels[progress]; + return ( + + + + + + + + + + + ); +}; + +export default FeedbackDialog; diff --git a/frontend/src/js/constants/userConstants.js b/frontend/src/js/constants/userConstants.js index 96888189..9dc6223a 100644 --- a/frontend/src/js/constants/userConstants.js +++ b/frontend/src/js/constants/userConstants.js @@ -391,6 +391,7 @@ export const SET_SHOW_CONNECT_DEVICE = 'SET_SHOW_CONNECT_DEVICE'; export const SET_TOOLTIP_STATE = 'SET_TOOLTIP_STATE'; export const SET_TOOLTIPS_STATE = 'SET_TOOLTIPS_STATE'; export const SET_SHOW_STARTUP_NOTIFICATION = 'SET_SHOW_STARTUP_NOTIFICATION'; +export const SET_SHOW_FEEDBACK_DIALOG = 'SET_SHOW_FEEDBACK_DIALOG'; export const OWN_USER_ID = 'me'; diff --git a/frontend/src/js/reducers/userReducer.js b/frontend/src/js/reducers/userReducer.js index 3af12c7a..5e774f36 100644 --- a/frontend/src/js/reducers/userReducer.js +++ b/frontend/src/js/reducers/userReducer.js @@ -34,6 +34,7 @@ export const initialState = { ...UserConstants.rolesById }, showConnectDeviceDialog: false, + showFeedbackDialog: true, showStartupNotification: false, tooltips: { byId: { @@ -183,6 +184,11 @@ const userReducer = (state = initialState, action) => { byId: action.value } }; + case UserConstants.SET_SHOW_FEEDBACK_DIALOG: + return { + ...state, + showFeedbackDialog: action.value + }; default: return state; }