Skip to content
This repository was archived by the owner on Nov 29, 2023. It is now read-only.

Commit dc79ffb

Browse files
authored
DX-3109 Add Feedback Buttons (#942)
* DX-3109 Add Feedback Buttons * update docusaurus to 2.3 * add cancel button * remove newlines * lots of updates and fixes * add mobile sizing * DX-3110 add button to api reference pages * use docusaurus 2.3.1 * add editUrl to migration guides to enable feedback * remove currently unused identity spec page * use variable for error message * fix footer wave render bug * actually fix it this time * begin writing tests * add unit tests * use abortController and await for fetch * move url to docusaurus config
1 parent b64a236 commit dc79ffb

31 files changed

+1164
-331
lines changed

site/cypress/e2e/tests/feedback.cy.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
context(`Test the 'Was This Helpful' Feedback process`, () => {
2+
before(() => {
3+
cy.visit('/docs/account');
4+
})
5+
6+
it('Verifies that the feedback buttons are rendered', () => {
7+
cy.get('.question-container').should('be.visible');
8+
});
9+
10+
it('Verifies the Yes, No, and Cancel buttons work properly', () => {
11+
cy.get('.question-container > :nth-child(2)').click();
12+
cy.get('.question-container').should('not.exist');
13+
cy.get('[placeholder="How was this page helpful? (Optional)"]').should('be.visible');
14+
cy.get('.multi-line-input').type('this should be cleared');
15+
cy.get('.single-line-input').type('[email protected]');
16+
cy.get('.destructive-button').click();
17+
cy.get('.question-container').should('be.visible');
18+
cy.get('.question-container > :nth-child(3)').click();
19+
cy.get('[placeholder="What can we improve? (Optional)"]').should('be.visible');
20+
cy.get('.multi-line-input').should('be.empty');
21+
cy.get('.single-line-input').should('be.empty');
22+
});
23+
24+
it('Verifies the email validity check works', () => {
25+
cy.get('.single-line-input').type('invalidEmail');
26+
cy.get('.secondary-button').click();
27+
cy.get('.input-box-note-invalid').should('contain.text', 'Please enter a valid email address.');
28+
cy.get('.single-line-input-invalid').type('@email.com');
29+
cy.get('.input-box-note-invalid').should('not.exist');
30+
});
31+
32+
it('Verifies Feedback submit happy path', () => {
33+
cy.reload();
34+
cy.get('.question-container > :nth-child(2)').click();
35+
cy.get('.multi-line-input').type('happy path');
36+
cy.get('.single-line-input').type('[email protected]');
37+
cy.intercept('POST', 'https://eowxoldwz4d7syt.m.pipedream.net', {
38+
statusCode: 204
39+
});
40+
cy.get('.secondary-button').click();
41+
cy.get('.feedback-submitted').should('contain.text', 'Thanks! Your feedback has been submitted. We will contact you at [email protected]');
42+
});
43+
44+
it('Verifies Feedback submit error path', () => {
45+
cy.visit('/apis/messaging');
46+
cy.get('.question-container > :nth-child(2)').click();
47+
cy.get('.multi-line-input').type('error path');
48+
cy.get('.single-line-input').type('[email protected]');
49+
cy.intercept('POST', 'https://eowxoldwz4d7syt.m.pipedream.net', {
50+
statusCode: 400
51+
});
52+
cy.get('.secondary-button').click();
53+
cy.get('.alert-error').should('be.be.visible');
54+
cy.get('.alert-error > .text').should('contain.text', 'There was an error submitting your feedback, please try again.');
55+
})
56+
})

site/docusaurus.config.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const globalSpec_v2 = fs.readFileSync('./specs/global-v2.yml', 'utf-8');
1919
const globalSpec_v3 = fs.readFileSync('./specs/global-v3.yml', 'utf-8');
2020
const globalSpec_beta = fs.readFileSync('./specs/global-beta.yml', 'utf-8');
2121
const insightsSpec = fs.readFileSync('./specs/insights.yml', 'utf-8');
22+
const pipedream = 'https://eowxoldwz4d7syt.m.pipedream.net';
2223
/* TODO ONEID-1304
2324
const identitySpec = fs.readFileSync('./specs/one-identity-management.yml', 'utf-8');
2425
*/
@@ -161,6 +162,8 @@ module.exports = {
161162

162163
ltsVersions: ltsVersions,
163164

165+
pipedream: pipedream,
166+
164167
// CSS Colors
165168
bwBlue: '#079CEE',
166169
voicePurple: '#9a59c5',
@@ -181,10 +184,11 @@ module.exports = {
181184
[
182185
'@docusaurus/plugin-content-docs',
183186
{
184-
id: 'miration-guides',
187+
id: 'migration-guides',
185188
path: 'migration-guides',
186189
routeBasePath: 'migration-guides',
187190
sidebarPath: require.resolve('./sidebarsMigrationGuides.js'),
191+
editUrl: 'https://github.com/Bandwidth/api-docs/edit/main/site/',
188192
},
189193
],
190194
'docusaurus-plugin-sass',

site/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
},
1818
"dependencies": {
1919
"@algolia/client-search": "^4.9.1",
20-
"@docusaurus/core": "^2.2.0",
21-
"@docusaurus/preset-classic": "^2.2.0",
20+
"@docusaurus/core": "^2.3.1",
21+
"@docusaurus/preset-classic": "^2.3.1",
2222
"@types/react": "^17.0.0",
2323
"bandwidth-redoc": "^1.2.0",
2424
"buffer": "^6.0.3",

site/src/components/Alert.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
export default function Alert({type, text}) {
4+
const icons = new Map([
5+
['success', 'check_circle'],
6+
['info', 'info'],
7+
['warning', 'report_problem'],
8+
['error', 'error_outline']
9+
])
10+
11+
return(
12+
<div className='alert-container'>
13+
<div className={`alert${`-${type}`}`}>
14+
<div className='icon'>{icons.get(type)}</div>
15+
<div className='text'>{text}</div>
16+
</div>
17+
</div>
18+
)
19+
}

site/src/components/ApiReference.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { useColorMode } from '@docusaurus/theme-common';
33
import { RedocStandalone } from 'bandwidth-redoc';
44
import { lightTheme, darkTheme } from '@site/src/css/redocTheme';
5+
import WasThisHelpful from './WasThisHelpful';
56

67
const RedocConfig = (props) => {
78
const {colorMode} = useColorMode();
@@ -19,11 +20,13 @@ const RedocConfig = (props) => {
1920
}
2021

2122
export default function ApiReference(props) {
23+
const pageId = props.downloadDefinitionUrl.substring(props.downloadDefinitionUrl.indexOf('site/') + 5);
2224
return (
2325
<main>
2426
<div className="RedocStandalone">
2527
<RedocConfig spec={props.spec} color={props.color} hideDownloadButton={props.hideDownloadButton} downloadDefinitionUrl={props.downloadDefinitionUrl}/>
2628
</div>
29+
<div className='was-this-helpful-wrapper'><WasThisHelpful pageId={pageId}/></div>
2730
</main>
2831
);
2932
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import React from 'react';
2+
import Loader from './Loader';
3+
4+
export default function SecondaryButton ({type, onClick, isLoading, isDisabled, text}) {
5+
return(
6+
<button onClick={onClick} className={`${type}-button${isDisabled ? `-disabled` : ``}`} disabled={isDisabled}>
7+
{isLoading ? <Loader ringSize='18'/> : text}
8+
</button>
9+
)
10+
}

site/src/components/Loader.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from 'react';
2+
3+
export default function Loader ({ringSize}) {
4+
5+
var ringStyle = {
6+
width: `${ringSize}px`,
7+
height: `${ringSize}px`
8+
}
9+
10+
return (
11+
<div className='loader'>
12+
<div className='ring' style={ringStyle}/>
13+
<div className='ring' style={ringStyle}/>
14+
<div className='ring' style={ringStyle}/>
15+
</div>
16+
);
17+
}
18+

site/src/components/MultiLineInput.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import React from 'react';
2+
3+
export default function MultiLineInput ({label, changeFunction, placeholder, value, valid=true, note}) {
4+
return(
5+
<div className='multi-line-input-container'>
6+
<div className="input-box-label">{label}</div>
7+
<textarea className={`multi-line-input${valid ? `` : `-invalid`}`} onChange={changeFunction} placeholder={placeholder} value={value} rows={3}></textarea>
8+
{note && <div className={`input-box-note${valid ? `` : `-invalid`}`}>{note}</div>}
9+
</div>
10+
)
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import React, { useState } from 'react';
2+
3+
export default function SingleLineInput ({label, changeFunction, placeholder, value, valid=true, note}) {
4+
return(
5+
<div className='single-line-input-container'>
6+
<div className='input-box-label'>{label}</div>
7+
<textarea className={`single-line-input${valid ? `` : `-invalid`}`} onChange={changeFunction} placeholder={placeholder} value={value} rows={1} wrap={'off'}></textarea>
8+
{note && <div className={`input-box-note${valid ? `` : `-invalid`}`}>{note}</div>}
9+
</div>
10+
)
11+
}

site/src/components/WasThisHelpful.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import React, { useState } from 'react';
2+
import Alert from './Alert';
3+
import MultiLineInput from './MultiLineInput';
4+
import SingleLineInput from './SingleLineInput';
5+
import InteractiveButton from './InteractiveButton';
6+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
7+
8+
export default function WasThisHelpful({pageId}) {
9+
const {siteConfig} = useDocusaurusContext();
10+
const controller = new AbortController();
11+
const [isHelpfulSubmitted, setIsHelpfulSubmitted] = useState(false);
12+
const [isFeedbackSubmitted, setIsFeedbackSubmitted] = useState(false);
13+
const [questionOpacity, setQuestionOpacity] = useState(1);
14+
const [feedbackOpacity, setFeedbackOpacity] = useState(1);
15+
const [feedbackQuality, setFeedbackQuality] = useState('');
16+
const [feedbackPlaceholder, setFeedbackPlaceholder] = useState('');
17+
const [userFeedback, setUserFeedback] = useState('');
18+
const [userEmail, setUserEmail] = useState('');
19+
const [isEmailValid, setIsEmailValid] = useState(true);
20+
const [emailNote, setEmailNote] = useState('');
21+
const [awaitingResponse, setAwaitingResponse] = useState(false);
22+
const [requestError, setRequestError] = useState(false);
23+
const [requestErrorMessage, setRequestErrorMessage] = useState('');
24+
const transitionTimeMs = 500;
25+
const pipedreamUrl = siteConfig.customFields.pipedream;
26+
const errorMessageString = 'There was an error submitting your feedback, please try again.';
27+
28+
var questionStyle = {
29+
opacity: `${questionOpacity}`,
30+
transition: `${transitionTimeMs}ms`
31+
};
32+
33+
var feedbackStyle = {
34+
opacity: `${feedbackOpacity}`,
35+
transition: `${transitionTimeMs}ms`
36+
};
37+
38+
const helpfulQuestion = () => {
39+
return(
40+
<div className="question-container" style={questionStyle}>
41+
<div className="question-text">Was this page helpful?</div>
42+
<InteractiveButton type={'secondary'} onClick={() => submitHelpful('good')} text={'Yes'}/>
43+
<InteractiveButton type={'secondary'} onClick={() => submitHelpful('bad')} text={'No'}/>
44+
</div>
45+
)
46+
};
47+
48+
const feedbackInput = () => {
49+
return(
50+
<div className="feedback-container" style={feedbackStyle}>
51+
<MultiLineInput label={'Feedback'} changeFunction={handleFeedback} placeholder={feedbackPlaceholder} value={userFeedback}/>
52+
<SingleLineInput label={'Email'} changeFunction={handleEmail} placeholder={'Optional'} value={userEmail} note={emailNote} valid={isEmailValid}/>
53+
<Alert type={'info'} text={'Your email will only be used to contact you regarding this feedback.'}/>
54+
<div className="feedback-buttons">
55+
<InteractiveButton type={'secondary'} onClick={submitFeedback} isDisabled={awaitingResponse} isLoading={awaitingResponse} text={'Submit'}/>
56+
<InteractiveButton type={'destructive'} onClick={cancelFeedback} isDisabled={awaitingResponse} text={'Cancel'}/>
57+
</div>
58+
{requestError && <Alert type={'error'} text={requestErrorMessage}/>}
59+
</div>
60+
)
61+
};
62+
63+
const feedbackSubmitted = () => {
64+
const thanks = 'Thanks! Your feedback has been submitted.';
65+
return(
66+
<div className="feedback-submitted">{userEmail ? `${thanks} We will contact you at ${userEmail}` : thanks}</div>
67+
)
68+
};
69+
70+
const handleFeedback = (e) => {
71+
setUserFeedback(e.target.value);
72+
};
73+
74+
const handleEmail = (e) => {
75+
setUserEmail(e.target.value);
76+
setIsEmailValid(true);
77+
setEmailNote('');
78+
}
79+
80+
const submitHelpful = (isHelpful) => {
81+
setFeedbackQuality(isHelpful);
82+
setQuestionOpacity(0);
83+
setFeedbackPlaceholder(isHelpful=='good' ? 'How was this page helpful? (Optional)' : 'What can we improve? (Optional)');
84+
85+
setTimeout(() => {
86+
setIsHelpfulSubmitted(true);
87+
setFeedbackOpacity(1);
88+
}, transitionTimeMs)
89+
};
90+
91+
const cancelFeedback = () => {
92+
setFeedbackOpacity(0);
93+
94+
setTimeout(() => {
95+
setIsHelpfulSubmitted(false);
96+
setQuestionOpacity(1);
97+
setIsEmailValid(true);
98+
setEmailNote('');
99+
setUserFeedback('');
100+
setUserEmail('');
101+
}, transitionTimeMs)
102+
};
103+
104+
const submitFeedback = async () => {
105+
setRequestError(false);
106+
107+
const emailRegex = /^\S+@\S+$/;
108+
if(userEmail && !emailRegex.test(userEmail)) {
109+
setIsEmailValid(false);
110+
setEmailNote('Please enter a valid email address.');
111+
return;
112+
}
113+
114+
const feedbackBody = {
115+
timestamp: new Date(Date.now()),
116+
pageId: pageId,
117+
feedbackType: feedbackQuality,
118+
feedbackString: userFeedback,
119+
userEmail: userEmail
120+
};
121+
setAwaitingResponse(true);
122+
123+
try {
124+
setTimeout(() => controller.abort(), 20000);
125+
const response = await fetch(pipedreamUrl, {method: 'POST', body: JSON.stringify(feedbackBody), signal: controller.signal});
126+
setAwaitingResponse(false);
127+
switch(response.status) {
128+
case 204:
129+
setFeedbackOpacity(0);
130+
setTimeout(() => {
131+
setIsFeedbackSubmitted(true);
132+
}, transitionTimeMs)
133+
break;
134+
case 400:
135+
setRequestError(true);
136+
setRequestErrorMessage(errorMessageString);
137+
break;
138+
default:
139+
setRequestError(true);
140+
setRequestErrorMessage(errorMessageString);
141+
break;
142+
}
143+
} catch(error) {
144+
setAwaitingResponse(false);
145+
if(error.name == 'AbortError') {
146+
setRequestError(true);
147+
setRequestErrorMessage('Your feedback request has timed out, please wait a few seconds and try again.');
148+
} else {
149+
setRequestError(true);
150+
setRequestErrorMessage(errorMessageString);
151+
}
152+
}
153+
154+
};
155+
156+
return (
157+
<div className="was-this-helpful">
158+
<hr/>
159+
{(!isHelpfulSubmitted && !isFeedbackSubmitted) && helpfulQuestion()}
160+
{(isHelpfulSubmitted && !isFeedbackSubmitted) && feedbackInput()}
161+
{(isHelpfulSubmitted && isFeedbackSubmitted) && feedbackSubmitted()}
162+
<hr/>
163+
</div>
164+
);
165+
}

0 commit comments

Comments
 (0)