diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb
index a07b1246bee..aa2c1ac9cef 100644
--- a/lib/gitlab/path_regex.rb
+++ b/lib/gitlab/path_regex.rb
@@ -58,6 +58,7 @@ module Gitlab
uploads
users
v2
+ visual-review-toolbar.js
].freeze
# This list should contain all words following `/*namespace_id/:project_id` in
diff --git a/public/visual-review-toolbar.js b/public/visual-review-toolbar.js
new file mode 100644
index 00000000000..dc21197bda9
--- /dev/null
+++ b/public/visual-review-toolbar.js
@@ -0,0 +1,626 @@
+///////////////////////////////////////////////
+/////////////////// STYLES ////////////////////
+///////////////////////////////////////////////
+
+const buttonClearStyles = `
+ -webkit-appearance: none;
+`;
+
+const buttonBaseStyles = `
+ cursor: pointer;
+ transition: background-color 100ms linear, border-color 100ms linear, color 100ms linear, box-shadow 100ms linear;
+`;
+
+const buttonSuccessActiveStyles = `
+ background-color: #168f48;
+ border-color: #12753a;
+ color: #fff;
+`;
+
+const buttonSuccessHoverStyles = `
+ color: #fff;
+ background-color: #137e3f;
+ border-color: #127339;
+`;
+
+const buttonSuccessStyles = `
+ ${buttonBaseStyles}
+ background-color: #1aaa55;
+ border: 1px solid #168f48;
+ color: #fff;
+`;
+
+const buttonSecondaryStyles = `
+ ${buttonBaseStyles}
+ background: none #fff;
+ margin: 0 .5rem;
+ border: 1px solid #e3e3e3;
+`;
+
+const buttonSecondaryActiveStyles = `
+ color: #2e2e2e;
+ background-color: #e1e1e1;
+ border-color: #dadada;
+`;
+
+const buttonSecondaryHoverStyles = `
+ background-color: #f0f0f0;
+ border-color: #e3e3e3;
+ color: #2e2e2e;
+`;
+
+const buttonWideStyles = `
+ width: 100%;
+`;
+
+const buttonWrapperStyles = `
+ margin-top: 1rem;
+ display: flex;
+ align-items: baseline;
+ justify-content: flex-end;
+`;
+
+const collapseStyles = `
+ ${buttonBaseStyles}
+ width: 2.4rem;
+ height: 2.2rem;
+ margin-left: 1rem;
+ padding: .5rem;
+`;
+
+const collapseClosedStyles = `
+ ${collapseStyles}
+ align-self: center;
+`;
+
+const collapseOpenStyles = `
+ ${collapseStyles}
+`;
+
+const checkboxLabelStyles = `
+ padding: 0 .2rem;
+`;
+
+const checkboxWrapperStyles = `
+ display: flex;
+ align-items: baseline;
+`;
+
+const formStyles = `
+ display: flex;
+ flex-direction: column;
+ width: 100%
+`;
+
+const labelStyles = `
+ font-weight: 600;
+ display: inline-block;
+ width: 100%;
+`;
+
+const linkStyles = `
+ color: #1b69b6;
+ text-decoration: none;
+ background-color: transparent;
+ background-image: none;
+`;
+
+const messageStyles = `
+ padding: .25rem 0;
+ margin: 0;
+ line-height: 1.2rem;
+`;
+
+const metadataNoteStyles = `
+ font-size: .7rem;
+ line-height: 1rem;
+ color: #666;
+ margin-bottom: 0;
+`;
+
+const inputStyles = `
+ width: 100%;
+ border: 1px solid #dfdfdf;
+ border-radius: 4px;
+ padding: .1rem .2rem;
+ min-height: 2rem;
+ max-width: 17rem;
+`;
+
+const svgInnerStyles = `
+ pointer-events: none;
+`;
+
+const wrapperClosedStyles = `
+ max-width: 3.4rem;
+ max-height: 3.4rem;
+`;
+
+const wrapperOpenStyles = `
+ max-width: 22rem;
+ max-height: 22rem;
+`;
+
+const wrapperStyles = `
+ max-width: 22rem;
+ max-height: 22rem;
+ overflow: scroll;
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ display: flex;
+ flex-direction: row-reverse;
+ padding: 1rem;
+ background-color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
+ 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ font-size: .8rem;
+ font-weight: 400;
+ color: #2e2e2e;
+`;
+
+const gitlabStyles = `
+ #gitlab-collapse > * {
+ ${svgInnerStyles}
+ }
+
+ #gitlab-form-wrapper {
+ ${formStyles}
+ }
+
+ #gitlab-review-container {
+ ${wrapperStyles}
+ }
+
+ .gitlab-open-wrapper {
+ ${wrapperOpenStyles}
+ }
+
+ .gitlab-closed-wrapper {
+ ${wrapperClosedStyles}
+ }
+
+ .gitlab-button-secondary {
+ ${buttonSecondaryStyles}
+ }
+
+ .gitlab-button-secondary:hover {
+ ${buttonSecondaryHoverStyles}
+ }
+
+ .gitlab-button-secondary:active {
+ ${buttonSecondaryActiveStyles}
+ }
+
+ .gitlab-button-success:hover {
+ ${buttonSuccessHoverStyles}
+ }
+
+ .gitlab-button-success:active {
+ ${buttonSuccessActiveStyles}
+ }
+
+ .gitlab-button-success {
+ ${buttonSuccessStyles}
+ }
+
+ .gitlab-button-wide {
+ ${buttonWideStyles}
+ }
+
+ .gitlab-button-wrapper {
+ ${buttonWrapperStyles}
+ }
+
+ .gitlab-collapse-closed {
+ ${collapseClosedStyles}
+ }
+
+ .gitlab-collapse-open {
+ ${collapseOpenStyles}
+ }
+
+ .gitlab-checkbox-label {
+ ${checkboxLabelStyles}
+ }
+
+ .gitlab-checkbox-wrapper {
+ ${checkboxWrapperStyles}
+ }
+
+ .gitlab-label {
+ ${labelStyles}
+ }
+
+ .gitlab-link {
+ ${linkStyles}
+ }
+
+ .gitlab-message {
+ ${messageStyles}
+ }
+
+ .gitlab-metadata-note {
+ ${metadataNoteStyles}
+ }
+
+ .gitlab-input {
+ ${inputStyles}
+ }
+`;
+
+function addStylesheet() {
+ const styleEl = document.createElement('style');
+ styleEl.insertAdjacentHTML('beforeend', gitlabStyles);
+ document.head.appendChild(styleEl);
+}
+
+///////////////////////////////////////////////
+/////////////////// STATE ////////////////////
+///////////////////////////////////////////////
+const data = {};
+
+///////////////////////////////////////////////
+///////////////// COMPONENTS //////////////////
+///////////////////////////////////////////////
+const note = `
+
+`;
+
+const comment = `
+
+
+ ${note}
+
Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.
+
+
+
+
+
+`;
+
+const commentIcon = `
+
+`
+
+const compressIcon = `
+
+`;
+
+const collapseButton = `
+
+`;
+
+
+const form = (content) => `
+
+ ${content}
+
+`;
+
+const login = `
+
+
+
+
+
+
+
+
+`;
+
+///////////////////////////////////////////////
+//////////////// INTERACTIONS /////////////////
+///////////////////////////////////////////////
+
+// from https://developer.mozilla.org/en-US/docs/Web/API/Window/navigator
+function getBrowserId (sUsrAg) {
+ var aKeys = ["MSIE", "Edge", "Firefox", "Safari", "Chrome", "Opera"],
+ nIdx = aKeys.length - 1;
+
+ for (nIdx; nIdx > -1 && sUsrAg.indexOf(aKeys[nIdx]) === -1; nIdx--);
+ return aKeys[nIdx];
+}
+
+function addCommentForm () {
+ const formWrapper = document.getElementById('gitlab-form-wrapper');
+ formWrapper.innerHTML = comment;
+}
+
+function addLoginForm () {
+ const formWrapper = document.getElementById('gitlab-form-wrapper');
+ formWrapper.innerHTML = login;
+}
+
+function authorizeUser () {
+ // Clear any old errors
+ clearNote('gitlab-token');
+
+ const token = document.getElementById('gitlab-token').value;
+ const rememberMe = document.getElementById('remember_token').checked;
+
+ if (!token) {
+ postError('Please enter your token.', 'gitlab-token');
+ return;
+ }
+
+ if (rememberMe) {
+ storeToken(token);
+ }
+
+ authSuccess(token);
+ return;
+}
+
+function authSuccess (token) {
+ data.token = token;
+ addCommentForm();
+}
+
+
+function clearNote (inputId) {
+ const note = document.getElementById('gitlab-validation-note');
+ note.innerText = '';
+ note.style.color = '';
+
+ if (inputId) {
+ const field = document.getElementById(inputId);
+ field.style.borderColor = '';
+ }
+}
+
+function confirmAndClear (discussionId) {
+ const commentButton = document.getElementById('gitlab-comment-button');
+ const note = document.getElementById('gitlab-validation-note');
+
+ commentButton.innerText = 'Feedback sent';
+ note.innerText = `Your comment was successfully posted to issue #${discussionId}`;
+
+ setTimeout(resetCommentButton, 1000);
+}
+
+function getInitialState () {
+ const { localStorage } = window;
+
+ try {
+ let token = localStorage.getItem('token');
+
+ if (token) {
+ data.token = token;
+ return comment;
+ }
+
+ return login;
+
+ } catch (err) {
+ return login;
+ }
+}
+
+function getProjectDetails () {
+ const { innerWidth,
+ innerHeight,
+ location: { href },
+ navigator: {
+ platform, userAgent
+ } } = window;
+ const browser = getBrowserId(userAgent);
+
+ const scriptEl = document.getElementById('review-app-toolbar-script')
+ const { projectId, discussionId, mrUrl } = scriptEl.dataset;
+
+ return {
+ href,
+ platform,
+ browser,
+ userAgent,
+ innerWidth,
+ innerHeight,
+ projectId,
+ discussionId,
+ mrUrl,
+ };
+}
+
+function logoutUser () {
+ const { localStorage } = window;
+
+ // All the browsers we support have localStorage, so let's silently fail
+ // and go on with the rest of the functionality.
+ try {
+ localStorage.removeItem('token');
+ } catch (err) {
+ return;
+ }
+
+ addLoginForm();
+}
+
+function postComment ({
+ href,
+ platform,
+ browser,
+ userAgent,
+ innerWidth,
+ innerHeight,
+ projectId,
+ discussionId,
+ mrUrl,
+}) {
+ // Clear any old errors
+ clearNote('gitlab-comment');
+
+ setInProgressState();
+
+ const commentText = document.getElementById('gitlab-comment').value.trim();
+
+ if (!commentText) {
+ postError('Your comment appears to be empty.', 'gitlab-comment');
+ resetCommentBox();
+ return;
+ }
+
+ const detailText = `
+
+ Metadata
+ Posted from ${href} | ${platform} | ${browser} | ${innerWidth} x ${innerHeight}.
+
+ *User agent: ${userAgent}*
+
+ `;
+
+ const url = `
+ ${mrUrl}/api/v4/projects/${projectId}/issues/${discussionId}/discussions`;
+
+ const body = `${commentText}${detailText}`;
+
+ fetch(url, {
+ method: 'POST',
+ headers: {
+ 'PRIVATE-TOKEN': data.token,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ body }),
+ })
+ .then((response) => {
+ if (response.ok) {
+ confirmAndClear(discussionId);
+ return;
+ }
+
+ throw new Error(`${response.status}: ${response.statusText}`)
+ })
+ .catch((err) => {
+ postError(`The feedback was not sent successfully. Please try again. Error: ${err.message}`, 'gitlab-comment');
+ resetCommentBox();
+ });
+}
+
+function postError (message, inputId) {
+ const note = document.getElementById('gitlab-validation-note');
+ const field = document.getElementById(inputId);
+ field.style.borderColor = '#db3b21';
+ note.style.color = '#db3b21';
+ note.innerText = message;
+}
+
+function resetCommentBox() {
+ const commentBox = document.getElementById('gitlab-comment');
+ const commentButton = document.getElementById('gitlab-comment-button');
+
+ commentButton.innerText = 'Send feedback';
+ commentButton.classList.replace('gitlab-button-secondary', 'gitlab-button-success');
+ commentButton.style.opacity = 1;
+
+ commentBox.style.pointerEvents = 'auto';
+ commentBox.style.color = 'rgba(0, 0, 0, 1)';
+}
+
+function resetCommentButton() {
+ const commentBox = document.getElementById('gitlab-comment');
+ const note = document.getElementById('gitlab-validation-note');
+
+ commentBox.value = '';
+ note.innerText = '';
+ resetCommentBox();
+}
+
+function setInProgressState() {
+ const commentButton = document.getElementById('gitlab-comment-button');
+ const commentBox = document.getElementById('gitlab-comment');
+
+ commentButton.innerText = 'Sending feedback';
+ commentButton.classList.replace('gitlab-button-success', 'gitlab-button-secondary');
+ commentButton.style.opacity = 0.5;
+ commentBox.style.color = 'rgba(223, 223, 223, 0.5)';
+ commentBox.style.pointerEvents = 'none';
+}
+
+function storeToken (token) {
+
+ const { localStorage } = window;
+
+ // All the browsers we support have localStorage, so let's silently fail
+ // and go on with the rest of the functionality.
+ try {
+ localStorage.setItem('token', token);
+ } catch (err) {
+ return;
+ }
+}
+
+function toggleForm () {
+ const container = document.getElementById('gitlab-review-container');
+ const collapseButton = document.getElementById('gitlab-collapse');
+ const form = document.getElementById('gitlab-form-wrapper');
+ const OPEN = 'open';
+ const CLOSED = 'closed';
+
+ const stateVals = {
+ [OPEN]: {
+ buttonClasses: ['gitlab-collapse-closed', 'gitlab-collapse-open'],
+ containerClasses: ['gitlab-closed-wrapper', 'gitlab-open-wrapper'],
+ icon: compressIcon,
+ display: 'flex',
+ backgroundColor: 'rgba(255, 255, 255, 1)',
+ },
+ [CLOSED]: {
+ buttonClasses: ['gitlab-collapse-open', 'gitlab-collapse-closed'],
+ containerClasses: ['gitlab-open-wrapper', 'gitlab-closed-wrapper'],
+ icon: commentIcon,
+ display: 'none',
+ backgroundColor: 'rgba(255, 255, 255, 0)',
+ },
+ }
+
+ const nextState = collapseButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN;
+
+ container.classList.replace(...stateVals[nextState].containerClasses);
+ container.style.backgroundColor = stateVals[nextState].backgroundColor;
+ form.style.display = stateVals[nextState].display;
+ collapseButton.classList.replace(...stateVals[nextState].buttonClasses);
+ collapseButton.innerHTML = stateVals[nextState].icon;
+}
+
+///////////////////////////////////////////////
+///////////////// INJECTION //////////////////
+///////////////////////////////////////////////
+
+function noop() {};
+
+const eventLookup = ({target: { id }}) => {
+ switch (id) {
+ case 'gitlab-collapse':
+ return toggleForm;
+ case 'gitlab-comment-button':
+ const projectDetails = getProjectDetails();
+ return postComment.bind(null, projectDetails);
+ case 'gitlab-login':
+ return authorizeUser;
+ case 'gitlab-logout-button':
+ return logoutUser;
+ default:
+ return noop;
+ }
+};
+
+window.addEventListener('load', () => {
+ const content = getInitialState();
+ const container = document.createElement('div');
+
+ container.setAttribute('id', 'gitlab-review-container');
+ container.insertAdjacentHTML('beforeend', collapseButton);
+ container.insertAdjacentHTML('beforeend', form(content));
+
+ document.body.insertBefore(container, document.body.firstChild);
+ addStylesheet();
+
+ document.getElementById('gitlab-review-container').addEventListener('click', (event) => {
+ eventLookup(event)();
+ });
+});