diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh index 2fee75aa..abf4c914 100755 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -35,12 +35,14 @@ cat >/var/www/mender-gui/dist/env.js < { 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 isDarkMode = useSelector(getIsDarkMode); @@ -208,6 +210,7 @@ export const AppRoot = () => { {showDismissHelptipsDialog && } {showDeviceConnectionDialog && dispatch(setShowConnectingDialog(false))} />} {showStartupNotification && } + {showFeedbackDialog && } ) : (
diff --git a/frontend/src/js/components/common/dialogs/feedback.test.js b/frontend/src/js/components/common/dialogs/feedback.test.js new file mode 100644 index 00000000..7fb857f8 --- /dev/null +++ b/frontend/src/js/components/common/dialogs/feedback.test.js @@ -0,0 +1,35 @@ +// Copyright 2019 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 from 'react'; + +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { render } from '../../../../../tests/setupTests'; +import Feedback from './feedback'; + +describe('Feedback Component', () => { + it('works as intended', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const ui = ; + const { rerender } = render(ui); + await jest.runOnlyPendingTimersAsync(); + await user.click(screen.getByTitle('Satisfied')); + await waitFor(() => rerender(ui)); + expect(screen.getByText(/the most important thing/i)).toBeVisible(); + await user.type(screen.getByPlaceholderText(/your feedback/i), 'some feedback'); + await user.click(screen.getByRole('button', { name: /submit/i })); + expect(screen.getByText(/Thank you/i)).toBeVisible(); + }); +}); diff --git a/frontend/src/js/components/common/dialogs/feedback.tsx b/frontend/src/js/components/common/dialogs/feedback.tsx new file mode 100644 index 00000000..c6ff9a4d --- /dev/null +++ b/frontend/src/js/components/common/dialogs/feedback.tsx @@ -0,0 +1,172 @@ +// 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, + darken, + dialogClasses, + dialogTitleClasses, + iconButtonClasses, + lighten, + textFieldClasses +} from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; + +import actions from '@northern.tech/store/actions'; +import { TIMEOUTS } from '@northern.tech/store/constants'; +import { submitFeedback } from '@northern.tech/store/thunks'; +import { isDarkMode } from '@northern.tech/store/utils'; + +const { setShowFeedbackDialog } = actions; + +const useStyles = makeStyles()(theme => ({ + root: { + pointerEvents: 'none', + [`.${dialogClasses.paper}`]: { width: 350, bottom: 0, right: 0, position: 'absolute' }, + [`.${dialogTitleClasses.root}`]: { + alignSelf: 'flex-end', + padding: 0, + [`.${iconButtonClasses.root}`]: { marginBottom: theme.spacing(-1) } + }, + '.title': { + color: isDarkMode(theme.palette.mode) ? lighten(theme.palette.primary.main, 0.85) : 'inherit' + } + }, + columns: { gap: theme.spacing(2) }, + rating: { + [`.${iconButtonClasses.root}`]: { + borderRadius: theme.shape.borderRadius, + height: theme.spacing(6), + width: theme.spacing(6), + backgroundColor: isDarkMode(theme.palette.mode) ? darken(theme.palette.primary.main, 0.45) : 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(-1); + 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.oneSecond); + }, []); + + const onCloseClick = () => dispatch(setShowFeedbackDialog(false)); + + const onSubmit = () => { + setProgress(progress + 1); + dispatch(submitFeedback({ satisfaction: satisfactionLevels[satisfaction].title, feedback })); + }; + + const Component = progressionLevels[progress]; + return ( + + + + + + + + + + + ); +}; + +export default FeedbackDialog; diff --git a/frontend/src/js/components/header/header.js b/frontend/src/js/components/header/header.js index 1bd07fc8..6e0ed68a 100644 --- a/frontend/src/js/components/header/header.js +++ b/frontend/src/js/components/header/header.js @@ -36,6 +36,7 @@ import { } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import storeActions from '@northern.tech/store/actions'; import { READ_STATES, TIMEOUTS } from '@northern.tech/store/constants'; import { getAcceptedDevices, @@ -47,6 +48,7 @@ import { getIsEnterprise, getOrganization, getShowHelptips, + getUserRoles, getUserSettings } from '@northern.tech/store/selectors'; import { useAppInit } from '@northern.tech/store/storehooks'; @@ -61,6 +63,7 @@ import { switchUserOrganization } from '@northern.tech/store/thunks'; import dayjs from 'dayjs'; +import { jwtDecode } from 'jwt-decode'; import Cookies from 'universal-cookie'; import enterpriseLogo from '../../../assets/img/headerlogo-enterprise.png'; @@ -78,6 +81,8 @@ import DeviceNotifications from './devicenotifications'; import OfferHeader from './offerheader'; import TrialNotification from './trialnotification'; +const { setShowFeedbackDialog } = storeActions; + // Change this when a new feature/offer is introduced const currentOffer = { name: 'add-ons', @@ -230,6 +235,23 @@ const AccountMenu = () => { ); }; +const HEX_BASE = 16; +const date = dayjs().toISOString().split('T')[0]; +const pickAUser = ({ jti, probability }) => { + const daySessionUniqueId = `${jti}-${date}`; // jti can be unique for multiple user sessions, combined with a check at most once per day should be enough + const hashBuffer = new TextEncoder().encode(daySessionUniqueId); + return crypto.subtle.digest('SHA-256', hashBuffer).then(hashArrayBuffer => { + // convert the hash buffer to a hex string for easier processing towards a number + const hashHex = Array.from(new Uint8Array(hashArrayBuffer)) + .map(byte => byte.toString(HEX_BASE).padStart(2, '0')) + .join(''); + const hashInt = parseInt(hashHex.slice(0, 8), HEX_BASE); // convert the hex string to an integer, use first 8 chars for simplicity + const normalizedValue = hashInt / Math.pow(2, 32); // normalize the integer to a value between 0 and 1, within the 32bit range browsers default to + // select the user if the normalized value is below the probability threshold + return normalizedValue < probability; + }); +}; + export const Header = ({ isDarkMode }) => { const { classes } = useStyles(); const [gettingUser, setGettingUser] = useState(false); @@ -239,11 +261,13 @@ export const Header = ({ isDarkMode }) => { const { total: acceptedDevices = 0 } = useSelector(getAcceptedDevices); const announcement = useSelector(state => state.app.hostedAnnouncement); const deviceLimit = useSelector(getDeviceLimit); + const feedbackProbability = useSelector(state => state.app.feedbackProbability); const firstLoginAfterSignup = useSelector(state => state.app.firstLoginAfterSignup); - const { trackingConsentGiven: hasTrackingEnabled } = useSelector(getUserSettings); + const { feedbackCollectedAt, trackingConsentGiven: hasTrackingEnabled } = useSelector(getUserSettings); + const { isAdmin } = useSelector(getUserRoles); const inProgress = useSelector(state => state.deployments.byStatus.inprogress.total); const isEnterprise = useSelector(getIsEnterprise); - const { isDemoMode: demo, isHosted } = useSelector(getFeatures); + const { hasFeedbackEnabled, isDemoMode: demo, isHosted } = useSelector(getFeatures); const { isSearching, searchTerm, refreshTrigger } = useSelector(state => state.app.searchState); const { pending: pendingDevices } = useSelector(getDeviceCountsByStatus); const userSettingInitialized = useSelector(state => state.users.settingsInitialized); @@ -253,6 +277,7 @@ export const Header = ({ isDarkMode }) => { const dispatch = useDispatch(); const deviceTimer = useRef(); + const feedbackTimer = useRef(); useAppInit(userId); @@ -279,9 +304,23 @@ export const Header = ({ isDarkMode }) => { deviceTimer.current = setInterval(() => dispatch(getAllDeviceCounts()), TIMEOUTS.refreshDefault); return () => { clearInterval(deviceTimer.current); + clearTimeout(feedbackTimer.current); }; }, [dispatch]); + useEffect(() => { + const today = dayjs(); + const diff = dayjs.duration(dayjs(feedbackCollectedAt).diff(today)); + const isFeedbackEligible = diff.asMonths() > 3; + if (!hasFeedbackEnabled || !userSettingInitialized || !token || (feedbackCollectedAt && !isFeedbackEligible)) { + return; + } + const { jti } = jwtDecode(token); + pickAUser({ jti, probability: feedbackProbability }).then(isSelected => { + feedbackTimer.current = setTimeout(() => dispatch(setShowFeedbackDialog(isSelected)), TIMEOUTS.threeSeconds); + }); + }, [dispatch, feedbackCollectedAt, feedbackProbability, hasFeedbackEnabled, isAdmin, userSettingInitialized, token]); + const onSearch = useCallback((searchTerm, refreshTrigger) => dispatch(setSearchState({ refreshTrigger, searchTerm, page: 1 })), [dispatch]); const setHideOffer = () => { diff --git a/frontend/src/js/store/appSlice/index.ts b/frontend/src/js/store/appSlice/index.ts index 53c97643..a0cabf84 100644 --- a/frontend/src/js/store/appSlice/index.ts +++ b/frontend/src/js/store/appSlice/index.ts @@ -44,12 +44,14 @@ export const initialState = { hasMultitenancy: false, hasDeviceConfig: false, hasDeviceConnect: false, + hasFeedbackEnabled: false, hasMonitor: false, hasReporting: false, isDemoMode: false, isHosted: false, isEnterprise: false }, + feedbackProbability: 0.3, firstLoginAfterSignup: false, hostedAnnouncement: '', docsVersion: '', diff --git a/frontend/src/js/store/storehooks.test.tsx b/frontend/src/js/store/storehooks.test.tsx index ba44b51b..00374306 100644 --- a/frontend/src/js/store/storehooks.test.tsx +++ b/frontend/src/js/store/storehooks.test.tsx @@ -97,7 +97,10 @@ const appInitActions = [ } } }, - { type: appActions.setEnvironmentData.type, payload: { hostAddress: null, hostedAnnouncement: '', recaptchaSiteKey: '', stripeAPIKey: '', trackerCode: '' } }, + { + type: appActions.setEnvironmentData.type, + payload: { feedbackProbability: 0.3, hostAddress: null, hostedAnnouncement: '', recaptchaSiteKey: '', stripeAPIKey: '', trackerCode: '' } + }, { type: getLatestReleaseInfo.pending.type }, { type: getUserSettings.pending.type }, { type: getGlobalSettings.pending.type }, diff --git a/frontend/src/js/store/storehooks.ts b/frontend/src/js/store/storehooks.ts index b3e67dd6..ebad2df2 100644 --- a/frontend/src/js/store/storehooks.ts +++ b/frontend/src/js/store/storehooks.ts @@ -66,11 +66,14 @@ const featureFlags = [ 'hasDeltaProgress', 'hasDeviceConfig', 'hasDeviceConnect', + 'hasFeedbackEnabled', 'hasReporting', 'hasMonitor', 'isEnterprise' ]; +const environmentDatas = ['feedbackProbability', 'hostAddress', 'hostedAnnouncement', 'recaptchaSiteKey', 'stripeAPIKey', 'trackerCode']; + export const parseEnvironmentInfo = () => (dispatch, getState) => { const state = getState(); let onboardingComplete = state.onboarding.complete || !!JSON.parse(window.localStorage.getItem('onboardingComplete') ?? 'false'); @@ -83,27 +86,16 @@ export const parseEnvironmentInfo = () => (dispatch, getState) => { features = {}, demoArtifactPort: port, disableOnboarding, - hostAddress, - hostedAnnouncement, integrationVersion, isDemoMode, menderVersion, menderArtifactVersion, metaMenderVersion, - recaptchaSiteKey, - services = {}, - stripeAPIKey, - trackerCode + services = {} } = mender_environment; onboardingComplete = stringToBoolean(features.isEnterprise) || stringToBoolean(disableOnboarding) || onboardingComplete; demoArtifactPort = port || demoArtifactPort; - environmentData = { - hostedAnnouncement: hostedAnnouncement || state.app.hostedAnnouncement, - hostAddress: hostAddress || state.app.hostAddress, - recaptchaSiteKey: recaptchaSiteKey || state.app.recaptchaSiteKey, - stripeAPIKey: stripeAPIKey || state.app.stripeAPIKey, - trackerCode: trackerCode || state.app.trackerCode - }; + environmentData = environmentDatas.reduce((accu, flag) => ({ ...accu, [flag]: mender_environment[flag] || state.app[flag] }), {}); environmentFeatures = { ...featureFlags.reduce((accu, flag) => ({ ...accu, [flag]: stringToBoolean(features[flag]) }), {}), isHosted: features.isHosted || window.location.hostname.includes('hosted.mender.io'), @@ -159,7 +151,6 @@ export const useAppInit = userId => { const dispatch = useDispatch(); const isEnterprise = useSelector(getIsEnterprise); const { hasMultitenancy, isHosted } = useSelector(getFeatures); - // const user = useSelector(getCurrentUser); const devicesByStatus = useSelector(getDevicesByStatusSelector); const onboardingState = useSelector(getOnboardingStateSelector); let { columnSelection = [], trackingConsentGiven: hasTrackingEnabled, tooltips = {} } = useSelector(getUserSettingsSelector); diff --git a/frontend/src/js/store/usersSlice/index.ts b/frontend/src/js/store/usersSlice/index.ts index 58be74d9..9b7a28f8 100644 --- a/frontend/src/js/store/usersSlice/index.ts +++ b/frontend/src/js/store/usersSlice/index.ts @@ -41,6 +41,7 @@ export const initialState = { }, settingsInitialized: false, showConnectDeviceDialog: false, + showFeedbackDialog: false, showStartupNotification: false, tooltips: { byId: { @@ -134,6 +135,9 @@ export const usersSlice = createSlice({ ...action.payload }; }, + setShowFeedbackDialog: (state, action) => { + state.showFeedbackDialog = action.payload; + }, setShowConnectingDialog: (state, action) => { state.showConnectDeviceDialog = action.payload; }, diff --git a/frontend/src/js/store/usersSlice/thunks.ts b/frontend/src/js/store/usersSlice/thunks.ts index 116d9799..ed8a2fab 100644 --- a/frontend/src/js/store/usersSlice/thunks.ts +++ b/frontend/src/js/store/usersSlice/thunks.ts @@ -23,9 +23,11 @@ import { APPLICATION_JSON_CONTENT_TYPE, APPLICATION_JWT_CONTENT_TYPE, SSO_TYPES, + TIMEOUTS, apiRoot, emptyRole, - emptyUiPermissions + emptyUiPermissions, + tenantadmApiUrlv2 } from '@northern.tech/store/constants'; import { getOnboardingState, getOrganization, getTooltipsState, getUserSettings as getUserSettingsSelector } from '@northern.tech/store/selectors'; import { commonErrorFallback, commonErrorHandler } from '@northern.tech/store/store'; @@ -759,3 +761,14 @@ export const setAllTooltipsReadState = createAsyncThunk(`${sliceName}/toggleHelp const updatedTips = Object.keys(HELPTOOLTIPS).reduce((accu, id) => ({ ...accu, [id]: { readState } }), {}); return Promise.resolve(dispatch(actions.setTooltipsState(updatedTips))).then(() => dispatch(saveUserSettings())); }); + +export const submitFeedback = createAsyncThunk(`${sliceName}/submitFeedback`, ({ satisfaction, feedback, ...meta }, { dispatch }) => + GeneralApi.post(`${tenantadmApiUrlv2}/contact/support`, { + subject: 'feedback submission', + body: JSON.stringify({ feedback, satisfaction, meta }) + }).then(() => { + const today = new Date(); + dispatch(saveUserSettings({ feedbackCollectedAt: today.toISOString().split('T')[0] })); + setTimeout(() => dispatch(actions.setShowFeedbackDialog(false)), TIMEOUTS.threeSeconds); + }) +); diff --git a/frontend/tests/setupTests.js b/frontend/tests/setupTests.js index c0e736c7..50b462e2 100644 --- a/frontend/tests/setupTests.js +++ b/frontend/tests/setupTests.js @@ -23,6 +23,7 @@ import { yes } from '@northern.tech/store/constants'; import { getConfiguredStore } from '@northern.tech/store/store'; import '@testing-library/jest-dom'; import { act, cleanup, queryByRole, render, waitFor, within } from '@testing-library/react'; +import crypto from 'crypto'; import { setupServer } from 'msw/node'; import { MessageChannel } from 'worker_threads'; @@ -105,6 +106,9 @@ beforeAll(async () => { createDataChannel: () => {} }; }; + window.crypto.subtle = { + digest: (...args) => crypto.subtle.digest(...args) + }; createMocks(); server = setupServer(...handlers); await server.listen({ onUnhandledRequest: 'error' });