Skip to content

Commit

Permalink
feat: added feedback dialog
Browse files Browse the repository at this point in the history
Ticket: MEN-7355
Changelog: Title
Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
  • Loading branch information
mzedel committed Oct 8, 2024
1 parent de11ff6 commit 8c0a3ba
Show file tree
Hide file tree
Showing 11 changed files with 280 additions and 4 deletions.
1 change: 1 addition & 0 deletions frontend/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ cat >/var/www/mender-gui/dist/env.js <<EOF
hasDeviceConfig: "$HAVE_DEVICECONFIG",
hasDeviceConnect: "$HAVE_DEVICECONNECT",
hasDeltaProgress: "$HAVE_DELTA_PROGRESS",
hasFeedbackEnabled: "$HAVE_FEEDBACK_ENABLED",
hasMonitor: "$HAVE_MONITOR",
hasMultitenancy: "$HAVE_MULTITENANT",
hasReleaseTags: "$HAVE_RELEASE_TAGS",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/js/components/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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';
Expand Down Expand Up @@ -106,6 +107,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 isDarkMode = useSelector(getIsDarkMode);
Expand Down Expand Up @@ -208,6 +210,7 @@ export const AppRoot = () => {
{showDismissHelptipsDialog && <ConfirmDismissHelptips />}
{showDeviceConnectionDialog && <DeviceConnectionDialog onCancel={() => dispatch(setShowConnectingDialog(false))} />}
{showStartupNotification && <StartupNotificationDialog />}
{showFeedbackDialog && <FeedbackDialog />}
</div>
) : (
<div className={classes.public}>
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/js/components/common/dialogs/feedback.test.js
Original file line number Diff line number Diff line change
@@ -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 = <Feedback />;
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();
});
});
172 changes: 172 additions & 0 deletions frontend/src/js/components/common/dialogs/feedback.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`flexbox column ${classes.columns}`}>
<div className="title">How satisfied are you with Mender?</div>
<div className={`flexbox space-between ${classes.rating}`}>
{satisfactionLevels.map(({ Icon, title }, index) => (
<IconButton key={`satisfaction-${index}`} onClick={() => setSatisfaction(index)} title={title}>
<Icon fontSize="large" />
</IconButton>
))}
</div>
<div className="flexbox space-between muted">
{explanations.map((explanation, index) => (
<div className="slightly-smaller" key={`explanation-${index}`}>
{explanation}
</div>
))}
</div>
</div>
);
};

const TextEntry = ({ classes, feedback, onChangeFeedback, onSubmit }) => (
<div className={`flexbox column ${classes.columns} ${classes.text}`}>
<div className="title">What do you think is the most important thing to improve in Mender? (optional)</div>
<TextField
placeholder="Your feedback"
multiline
minRows={4}
onChange={({ target: { value } }) => onChangeFeedback(value)}
value={feedback}
variant="outlined"
/>
<Button className="submitButton" variant="contained" onClick={onSubmit}>
Submit Feedback
</Button>
</div>
);

const AppreciationNote = () => <p className="margin-top-none align-center title">Thank you for taking the time to share your thoughts!</p>;

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 (
<Dialog className={classes.root} open hideBackdrop disableEnforceFocus PaperProps={{ style: { pointerEvents: 'auto' } }}>
<DialogTitle>
<IconButton onClick={onCloseClick} aria-label="close" size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<Component classes={classes} feedback={feedback} setSatisfaction={setSatisfaction} onChangeFeedback={setFeedback} onSubmit={onSubmit} />
</DialogContent>
</Dialog>
);
};

export default FeedbackDialog;
43 changes: 41 additions & 2 deletions frontend/src/js/components/header/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -47,6 +48,7 @@ import {
getIsEnterprise,
getOrganization,
getShowHelptips,
getUserRoles,
getUserSettings
} from '@northern.tech/store/selectors';
import { useAppInit } from '@northern.tech/store/storehooks';
Expand All @@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -253,6 +277,7 @@ export const Header = ({ isDarkMode }) => {

const dispatch = useDispatch();
const deviceTimer = useRef();
const feedbackTimer = useRef();

useAppInit(userId);

Expand All @@ -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 = () => {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/js/store/appSlice/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const initialState = {
hasMultitenancy: false,
hasDeviceConfig: false,
hasDeviceConnect: false,
hasFeedbackEnabled: false,
hasMonitor: false,
hasReporting: false,
isDemoMode: false,
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/js/store/storehooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions frontend/src/js/store/storehooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const featureFlags = [
'hasDeltaProgress',
'hasDeviceConfig',
'hasDeviceConnect',
'hasFeedbackEnabled',
'hasReporting',
'hasMonitor',
'isEnterprise'
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/js/store/usersSlice/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const initialState = {
},
settingsInitialized: false,
showConnectDeviceDialog: false,
showFeedbackDialog: false,
showStartupNotification: false,
tooltips: {
byId: {
Expand Down Expand Up @@ -134,6 +135,9 @@ export const usersSlice = createSlice({
...action.payload
};
},
setShowFeedbackDialog: (state, action) => {
state.showFeedbackDialog = action.payload;
},
setShowConnectingDialog: (state, action) => {
state.showConnectDeviceDialog = action.payload;
},
Expand Down
Loading

0 comments on commit 8c0a3ba

Please sign in to comment.