From 6bfdf529f5b6f604e1d157fa379a108d1b7f8586 Mon Sep 17 00:00:00 2001 From: Sarah Groff Hennigh-Palermo Date: Mon, 29 Jul 2019 15:42:42 +0000 Subject: [PATCH] Add MR form to runtime Visual Review configuration * adds the ability to enter a merge request ID directly into the toolbar, * adds the option to save the ID to `localStorage` (as with the token), * adds a link to change the ID once entered * adds some more explanatory errors for 401 & 404 * saves the comment to session storage if the user navigates away without posting it --- .../components/comment.js | 177 ++++-------------- .../components/comment_mr_note.js | 31 +++ .../components/comment_post.js | 145 ++++++++++++++ .../components/comment_storage.js | 20 ++ .../components/form_elements.js | 17 ++ .../visual_review_toolbar/components/index.js | 28 +-- .../visual_review_toolbar/components/login.js | 46 +++-- .../visual_review_toolbar/components/mr_id.js | 63 +++++++ .../visual_review_toolbar/components/note.js | 2 +- .../visual_review_toolbar/components/utils.js | 9 +- .../components/wrapper.js | 47 ++--- .../visual_review_toolbar/index.js | 25 ++- .../{components => shared}/constants.js | 12 +- .../visual_review_toolbar/shared/index.js | 49 +++++ .../shared/storage_utils.js | 42 +++++ .../visual_review_toolbar/store/events.js | 45 ++++- .../visual_review_toolbar/store/index.js | 14 +- .../visual_review_toolbar/store/state.js | 69 ++++--- .../visual_review_toolbar/styles/toolbar.css | 16 +- changelogs/unreleased/64190-add-mr-form.yml | 5 + 20 files changed, 591 insertions(+), 271 deletions(-) create mode 100644 app/assets/javascripts/visual_review_toolbar/components/comment_mr_note.js create mode 100644 app/assets/javascripts/visual_review_toolbar/components/comment_post.js create mode 100644 app/assets/javascripts/visual_review_toolbar/components/comment_storage.js create mode 100644 app/assets/javascripts/visual_review_toolbar/components/form_elements.js create mode 100644 app/assets/javascripts/visual_review_toolbar/components/mr_id.js rename app/assets/javascripts/visual_review_toolbar/{components => shared}/constants.js (76%) create mode 100644 app/assets/javascripts/visual_review_toolbar/shared/index.js create mode 100644 app/assets/javascripts/visual_review_toolbar/shared/storage_utils.js create mode 100644 changelogs/unreleased/64190-add-mr-form.yml diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment.js b/app/assets/javascripts/visual_review_toolbar/components/comment.js index 04bfb5e9532..20effc1751d 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/comment.js +++ b/app/assets/javascripts/visual_review_toolbar/components/comment.js @@ -1,148 +1,39 @@ -import { BLACK, COMMENT_BOX, MUTED, LOGOUT } from './constants'; -import { clearNote, postError } from './note'; -import { - buttonClearStyles, - selectCommentBox, - selectCommentButton, - selectNote, - selectNoteContainer, -} from './utils'; +import { nextView } from '../store'; +import { localStorage, COMMENT_BOX, LOGOUT } from '../shared'; +import { clearNote } from './note'; +import { buttonClearStyles } from './utils'; +import { addForm } from './wrapper'; +import { changeSelectedMr, selectedMrNote } from './comment_mr_note'; +import postComment from './comment_post'; +import { saveComment, getSavedComment } from './comment_storage'; -const comment = ` -
- - -
-
- - -
-`; +const comment = state => { + const savedComment = getSavedComment(); -const resetCommentButton = () => { - const commentButton = selectCommentButton(); - - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - commentButton.innerText = 'Send feedback'; - commentButton.classList.replace('gitlab-button-secondary', 'gitlab-button-success'); - commentButton.style.opacity = 1; -}; - -const resetCommentBox = () => { - const commentBox = selectCommentBox(); - commentBox.style.pointerEvents = 'auto'; - commentBox.style.color = BLACK; -}; - -const resetCommentText = () => { - const commentBox = selectCommentBox(); - commentBox.value = ''; -}; - -const resetComment = () => { - resetCommentButton(); - resetCommentBox(); - resetCommentText(); -}; - -const confirmAndClear = feedbackInfo => { - const commentButton = selectCommentButton(); - const currentNote = selectNote(); - const noteContainer = selectNoteContainer(); - - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - commentButton.innerText = 'Feedback sent'; - noteContainer.style.visibility = 'visible'; - currentNote.insertAdjacentHTML('beforeend', feedbackInfo); - - setTimeout(resetComment, 1000); - setTimeout(clearNote, 6000); -}; - -const setInProgressState = () => { - const commentButton = selectCommentButton(); - const commentBox = selectCommentBox(); - - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - commentButton.innerText = 'Sending feedback'; - commentButton.classList.replace('gitlab-button-success', 'gitlab-button-secondary'); - commentButton.style.opacity = 0.5; - commentBox.style.color = MUTED; - commentBox.style.pointerEvents = 'none'; -}; - -const postComment = ({ - href, - platform, - browser, - userAgent, - innerWidth, - innerHeight, - projectId, - projectPath, - mergeRequestId, - mrUrl, - token, -}) => { - // Clear any old errors - clearNote(COMMENT_BOX); - - setInProgressState(); - - const commentText = selectCommentBox().value.trim(); - - if (!commentText) { - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - postError('Your comment appears to be empty.', COMMENT_BOX); - resetCommentBox(); - resetCommentButton(); - return; - } - - const detailText = ` - \n -
- Metadata - Posted from ${href} | ${platform} | ${browser} | ${innerWidth} x ${innerHeight}. -

- User agent: ${userAgent} -
+ return ` +
+ + ${selectedMrNote(state)} + +
+
+ + +
`; - - const url = ` - ${mrUrl}/api/v4/projects/${projectId}/merge_requests/${mergeRequestId}/discussions`; - - const body = `${commentText} ${detailText}`; - - fetch(url, { - method: 'POST', - headers: { - 'PRIVATE-TOKEN': token, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ body }), - }) - .then(response => { - if (response.ok) { - return response.json(); - } - - throw new Error(`${response.status}: ${response.statusText}`); - }) - .then(data => { - const commentId = data.notes[0].id; - const feedbackLink = `${mrUrl}/${projectPath}/merge_requests/${mergeRequestId}#note_${commentId}`; - const feedbackInfo = `Feedback sent. View at ${projectPath} #${mergeRequestId} (comment ${commentId})`; - confirmAndClear(feedbackInfo); - }) - .catch(err => { - postError( - `Your comment could not be sent. Please try again. Error: ${err.message}`, - COMMENT_BOX, - ); - resetCommentBox(); - resetCommentButton(); - }); }; -export { comment, postComment }; +// This function is here becaause it is called only from the comment view +// If we reach a design where we can logout from multiple views, promote this +// to it's own package +const logoutUser = state => { + localStorage.removeItem('token'); + localStorage.removeItem('mergeRequestId'); + state.token = ''; + state.mergeRequestId = ''; + + clearNote(); + addForm(nextView(state, COMMENT_BOX)); +}; + +export { changeSelectedMr, comment, logoutUser, postComment, saveComment }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment_mr_note.js b/app/assets/javascripts/visual_review_toolbar/components/comment_mr_note.js new file mode 100644 index 00000000000..f71ffbf4f20 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/comment_mr_note.js @@ -0,0 +1,31 @@ +import { nextView } from '../store'; +import { localStorage, CHANGE_MR_ID_BUTTON, COMMENT_BOX } from '../shared'; +import { clearNote } from './note'; +import { buttonClearStyles } from './utils'; +import { addForm } from './wrapper'; + +const selectedMrNote = state => { + const { mrUrl, projectPath, mergeRequestId } = state; + + const mrLink = `${mrUrl}/${projectPath}/merge_requests/${mergeRequestId}`; + + return ` +

+ This posts to merge request !${mergeRequestId}. + +

+ `; +}; + +const clearMrId = state => { + localStorage.removeItem('mergeRequestId'); + state.mergeRequestId = ''; +}; + +const changeSelectedMr = state => { + clearMrId(state); + clearNote(); + addForm(nextView(state, COMMENT_BOX)); +}; + +export { changeSelectedMr, selectedMrNote }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment_post.js b/app/assets/javascripts/visual_review_toolbar/components/comment_post.js new file mode 100644 index 00000000000..ee5f2b62425 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/comment_post.js @@ -0,0 +1,145 @@ +import { BLACK, COMMENT_BOX, MUTED } from '../shared'; +import { clearSavedComment } from './comment_storage'; +import { clearNote, postError } from './note'; +import { selectCommentBox, selectCommentButton, selectNote, selectNoteContainer } from './utils'; + +const resetCommentButton = () => { + const commentButton = selectCommentButton(); + + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + commentButton.innerText = 'Send feedback'; + commentButton.classList.replace('gitlab-button-secondary', 'gitlab-button-success'); + commentButton.style.opacity = 1; +}; + +const resetCommentBox = () => { + const commentBox = selectCommentBox(); + commentBox.style.pointerEvents = 'auto'; + commentBox.style.color = BLACK; +}; + +const resetCommentText = () => { + const commentBox = selectCommentBox(); + commentBox.value = ''; + clearSavedComment(); +}; + +const resetComment = () => { + resetCommentButton(); + resetCommentBox(); + resetCommentText(); +}; + +const confirmAndClear = feedbackInfo => { + const commentButton = selectCommentButton(); + const currentNote = selectNote(); + const noteContainer = selectNoteContainer(); + + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + commentButton.innerText = 'Feedback sent'; + noteContainer.style.visibility = 'visible'; + currentNote.insertAdjacentHTML('beforeend', feedbackInfo); + + setTimeout(resetComment, 1000); + setTimeout(clearNote, 6000); +}; + +const setInProgressState = () => { + const commentButton = selectCommentButton(); + const commentBox = selectCommentBox(); + + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + commentButton.innerText = 'Sending feedback'; + commentButton.classList.replace('gitlab-button-success', 'gitlab-button-secondary'); + commentButton.style.opacity = 0.5; + commentBox.style.color = MUTED; + commentBox.style.pointerEvents = 'none'; +}; + +const commentErrors = error => { + switch (error.status) { + case 401: + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + return 'Unauthorized. You may have entered an incorrect authentication token.'; + case 404: + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + return 'Not found. You may have entered an incorrect merge request ID.'; + default: + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + return `Your comment could not be sent. Please try again. Error: ${error.message}`; + } +}; + +const postComment = ({ + platform, + browser, + userAgent, + innerWidth, + innerHeight, + projectId, + projectPath, + mergeRequestId, + mrUrl, + token, +}) => { + // Clear any old errors + clearNote(COMMENT_BOX); + + setInProgressState(); + + const commentText = selectCommentBox().value.trim(); + // Get the href at the last moment to support SPAs + const { href } = window.location; + + if (!commentText) { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + postError('Your comment appears to be empty.', COMMENT_BOX); + resetCommentBox(); + resetCommentButton(); + return; + } + + const detailText = ` + \n +
+ Metadata + Posted from ${href} | ${platform} | ${browser} | ${innerWidth} x ${innerHeight}. +

+ User agent: ${userAgent} +
+ `; + + const url = ` + ${mrUrl}/api/v4/projects/${projectId}/merge_requests/${mergeRequestId}/discussions`; + + const body = `${commentText} ${detailText}`; + + fetch(url, { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ body }), + }) + .then(response => { + if (response.ok) { + return response.json(); + } + + throw response; + }) + .then(data => { + const commentId = data.notes[0].id; + const feedbackLink = `${mrUrl}/${projectPath}/merge_requests/${mergeRequestId}#note_${commentId}`; + const feedbackInfo = `Feedback sent. View at ${projectPath} !${mergeRequestId} (comment ${commentId})`; + confirmAndClear(feedbackInfo); + }) + .catch(err => { + postError(commentErrors(err), COMMENT_BOX); + resetCommentBox(); + resetCommentButton(); + }); +}; + +export default postComment; diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment_storage.js b/app/assets/javascripts/visual_review_toolbar/components/comment_storage.js new file mode 100644 index 00000000000..32a9e7e2f05 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/comment_storage.js @@ -0,0 +1,20 @@ +import { selectCommentBox } from './utils'; +import { sessionStorage } from '../shared'; + +const getSavedComment = () => sessionStorage.getItem('comment') || ''; + +const saveComment = () => { + const currentComment = selectCommentBox(); + + // This may be added to any view via top-level beforeunload listener + // so let's skip if it does not apply + if (currentComment && currentComment.value) { + sessionStorage.setItem('comment', currentComment.value); + } +}; + +const clearSavedComment = () => { + sessionStorage.removeItem('comment'); +}; + +export { getSavedComment, saveComment, clearSavedComment }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/form_elements.js b/app/assets/javascripts/visual_review_toolbar/components/form_elements.js new file mode 100644 index 00000000000..608488a6fea --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/form_elements.js @@ -0,0 +1,17 @@ +import { REMEMBER_ITEM } from '../shared'; +import { buttonClearStyles } from './utils'; + +/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ +const rememberBox = (rememberText = 'Remember me') => ` +
+ + +
+`; + +const submitButton = buttonId => ` +
+ +
+`; +export { rememberBox, submitButton }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/index.js b/app/assets/javascripts/visual_review_toolbar/components/index.js index 50b52d7d3a2..e88b3637ad8 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/index.js +++ b/app/assets/javascripts/visual_review_toolbar/components/index.js @@ -1,33 +1,23 @@ -import { comment, postComment } from './comment'; -import { - COLLAPSE_BUTTON, - COMMENT_BUTTON, - FORM_CONTAINER, - LOGIN, - LOGOUT, - REVIEW_CONTAINER, -} from './constants'; +import { changeSelectedMr, comment, logoutUser, postComment, saveComment } from './comment'; import { authorizeUser, login } from './login'; +import { addMr, mrForm } from './mr_id'; import { note } from './note'; -import { selectContainer } from './utils'; -import { buttonAndForm, logoutUser, toggleForm } from './wrapper'; -import { collapseButton } from './wrapper_icons'; +import { selectContainer, selectForm } from './utils'; +import { buttonAndForm, toggleForm } from './wrapper'; export { + addMr, authorizeUser, buttonAndForm, - collapseButton, + changeSelectedMr, comment, login, logoutUser, + mrForm, note, postComment, + saveComment, selectContainer, + selectForm, toggleForm, - COLLAPSE_BUTTON, - COMMENT_BUTTON, - FORM_CONTAINER, - LOGIN, - LOGOUT, - REVIEW_CONTAINER, }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/login.js b/app/assets/javascripts/visual_review_toolbar/components/login.js index 0a71299f041..4a6976ef2fd 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/login.js +++ b/app/assets/javascripts/visual_review_toolbar/components/login.js @@ -1,35 +1,31 @@ -import { LOGIN, REMEMBER_TOKEN, TOKEN_BOX } from './constants'; +import { nextView } from '../store'; +import { localStorage, LOGIN, TOKEN_BOX } from '../shared'; import { clearNote, postError } from './note'; -import { buttonClearStyles, selectRemember, selectToken } from './utils'; -import { addCommentForm } from './wrapper'; +import { rememberBox, submitButton } from './form_elements'; +import { selectRemember, selectToken } from './utils'; +import { addForm } from './wrapper'; + +const labelText = ` + Enter your personal access token +`; const login = ` -
- - -
-
- - -
-
- -
+
+ + +
+ ${rememberBox()} + ${submitButton(LOGIN)} `; const storeToken = (token, state) => { - const { localStorage } = window; const rememberMe = selectRemember().checked; - // All the browsers we support have localStorage, so let's silently fail - // and go on with the rest of the functionality. - try { - if (rememberMe) { - localStorage.setItem('token', token); - } - } finally { - state.token = token; + if (rememberMe) { + localStorage.setItem('token', token); } + + state.token = token; }; const authorizeUser = state => { @@ -45,7 +41,7 @@ const authorizeUser = state => { } storeToken(token, state); - addCommentForm(); + addForm(nextView(state, LOGIN)); }; -export { authorizeUser, login }; +export { authorizeUser, login, storeToken }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/mr_id.js b/app/assets/javascripts/visual_review_toolbar/components/mr_id.js new file mode 100644 index 00000000000..f51e9631dd2 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/mr_id.js @@ -0,0 +1,63 @@ +import { nextView } from '../store'; +import { MR_ID, MR_ID_BUTTON, localStorage } from '../shared'; +import { clearNote, postError } from './note'; +import { rememberBox, submitButton } from './form_elements'; +import { selectForm, selectMrBox, selectRemember } from './utils'; +import { addForm } from './wrapper'; + +/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ +const mrLabel = `Enter your merge request ID`; +/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ +const mrRememberText = `Remember this number`; + +const mrForm = ` +
+ + +
+ ${rememberBox(mrRememberText)} + ${submitButton(MR_ID_BUTTON)} +`; + +const storeMR = (id, state) => { + const rememberMe = selectRemember().checked; + + if (rememberMe) { + localStorage.setItem('mergeRequestId', id); + } + + state.mergeRequestId = id; +}; + +const getFormError = (mrNumber, form) => { + if (!mrNumber) { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + return 'Please enter your merge request ID number.'; + } + + if (!form.checkValidity()) { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + return 'Please remove any non-number values from the field.'; + } + + return null; +}; + +const addMr = state => { + // Clear any old errors + clearNote(MR_ID); + + const mrNumber = selectMrBox().value; + const form = selectForm(); + const formError = getFormError(mrNumber, form); + + if (formError) { + postError(formError, MR_ID); + return; + } + + storeMR(mrNumber, state); + addForm(nextView(state, MR_ID)); +}; + +export { addMr, mrForm, storeMR }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/note.js b/app/assets/javascripts/visual_review_toolbar/components/note.js index 0150f640aae..9cddcb710f2 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/note.js +++ b/app/assets/javascripts/visual_review_toolbar/components/note.js @@ -1,4 +1,4 @@ -import { NOTE, NOTE_CONTAINER, RED } from './constants'; +import { NOTE, NOTE_CONTAINER, RED } from '../shared'; import { selectById, selectNote, selectNoteContainer } from './utils'; const note = ` diff --git a/app/assets/javascripts/visual_review_toolbar/components/utils.js b/app/assets/javascripts/visual_review_toolbar/components/utils.js index 00f4460925d..4ec9bd4a32a 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/utils.js +++ b/app/assets/javascripts/visual_review_toolbar/components/utils.js @@ -6,12 +6,13 @@ import { COMMENT_BUTTON, FORM, FORM_CONTAINER, + MR_ID, NOTE, NOTE_CONTAINER, - REMEMBER_TOKEN, + REMEMBER_ITEM, REVIEW_CONTAINER, TOKEN_BOX, -} from './constants'; +} from '../shared'; // this style must be applied inline in a handful of components /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ @@ -27,9 +28,10 @@ const selectCommentButton = () => document.getElementById(COMMENT_BUTTON); const selectContainer = () => document.getElementById(REVIEW_CONTAINER); const selectForm = () => document.getElementById(FORM); const selectFormContainer = () => document.getElementById(FORM_CONTAINER); +const selectMrBox = () => document.getElementById(MR_ID); const selectNote = () => document.getElementById(NOTE); const selectNoteContainer = () => document.getElementById(NOTE_CONTAINER); -const selectRemember = () => document.getElementById(REMEMBER_TOKEN); +const selectRemember = () => document.getElementById(REMEMBER_ITEM); const selectToken = () => document.getElementById(TOKEN_BOX); export { @@ -41,6 +43,7 @@ export { selectCommentButton, selectForm, selectFormContainer, + selectMrBox, selectNote, selectNoteContainer, selectRemember, diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js index f2eaf1d7916..fdf8ad7c41f 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js +++ b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js @@ -1,55 +1,32 @@ -import { comment } from './comment'; -import { CLEAR, FORM, FORM_CONTAINER, WHITE } from './constants'; -import { login } from './login'; -import { clearNote } from './note'; +import { CLEAR, FORM, FORM_CONTAINER, WHITE } from '../shared'; import { selectCollapseButton, selectForm, selectFormContainer, selectNoteContainer, } from './utils'; -import { commentIcon, compressIcon } from './wrapper_icons'; +import { collapseButton, commentIcon, compressIcon } from './wrapper_icons'; const form = content => ` -
+ ${content}
`; -const buttonAndForm = ({ content, toggleButton }) => ` +const buttonAndForm = content => `
- ${toggleButton} + ${collapseButton} ${form(content)}
`; -const addCommentForm = () => { +const addForm = nextForm => { const formWrapper = selectForm(); - formWrapper.innerHTML = comment; + formWrapper.innerHTML = nextForm; }; -const addLoginForm = () => { - const formWrapper = selectForm(); - formWrapper.innerHTML = login; -}; - -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; - } - - clearNote(); - addLoginForm(); -} - function toggleForm() { - const collapseButton = selectCollapseButton(); + const toggleButton = selectCollapseButton(); const currentForm = selectForm(); const formContainer = selectFormContainer(); const noteContainer = selectNoteContainer(); @@ -84,19 +61,19 @@ function toggleForm() { }, }; - const nextState = collapseButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN; + const nextState = toggleButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN; const currentVals = stateVals[nextState]; formContainer.classList.replace(...currentVals.containerClasses); formContainer.style.backgroundColor = currentVals.backgroundColor; formContainer.classList.toggle('gitlab-form-open'); currentForm.style.display = currentVals.display; - collapseButton.classList.replace(...currentVals.buttonClasses); - collapseButton.innerHTML = currentVals.icon; + toggleButton.classList.replace(...currentVals.buttonClasses); + toggleButton.innerHTML = currentVals.icon; if (noteContainer && noteContainer.innerText.length > 0) { noteContainer.style.display = currentVals.display; } } -export { addCommentForm, addLoginForm, buttonAndForm, logoutUser, toggleForm }; +export { addForm, buttonAndForm, toggleForm }; diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js index f94eb88835a..67b3fadd772 100644 --- a/app/assets/javascripts/visual_review_toolbar/index.js +++ b/app/assets/javascripts/visual_review_toolbar/index.js @@ -1,7 +1,8 @@ import './styles/toolbar.css'; -import { buttonAndForm, note, selectContainer, REVIEW_CONTAINER } from './components'; -import { debounce, eventLookup, getInitialView, initializeState, updateWindowSize } from './store'; +import { buttonAndForm, note, selectForm, selectContainer } from './components'; +import { REVIEW_CONTAINER } from './shared'; +import { eventLookup, getInitialView, initializeGlobalListeners, initializeState } from './store'; /* @@ -20,7 +21,7 @@ import { debounce, eventLookup, getInitialView, initializeState, updateWindowSiz window.addEventListener('load', () => { initializeState(window, document); - const mainContent = buttonAndForm(getInitialView(window)); + const mainContent = buttonAndForm(getInitialView()); const container = document.createElement('div'); container.setAttribute('id', REVIEW_CONTAINER); container.insertAdjacentHTML('beforeend', note); @@ -29,8 +30,22 @@ window.addEventListener('load', () => { document.body.insertBefore(container, document.body.firstChild); selectContainer().addEventListener('click', event => { - eventLookup(event)(); + eventLookup(event.target.id)(); }); - window.addEventListener('resize', debounce(updateWindowSize.bind(null, window), 200)); + selectForm().addEventListener('submit', event => { + // this is important to prevent the form from adding data + // as URL params and inadvertently revealing secrets + event.preventDefault(); + + const id = + event.target.querySelector('.gitlab-button-wrapper') && + event.target.querySelector('.gitlab-button-wrapper').getElementsByTagName('button')[0] && + event.target.querySelector('.gitlab-button-wrapper').getElementsByTagName('button')[0].id; + + // even if this is called with false, it's ok; it will get the default no-op + eventLookup(id)(); + }); + + initializeGlobalListeners(); }); diff --git a/app/assets/javascripts/visual_review_toolbar/components/constants.js b/app/assets/javascripts/visual_review_toolbar/shared/constants.js similarity index 76% rename from app/assets/javascripts/visual_review_toolbar/components/constants.js rename to app/assets/javascripts/visual_review_toolbar/shared/constants.js index 07fcb179d15..a56ea378b14 100644 --- a/app/assets/javascripts/visual_review_toolbar/components/constants.js +++ b/app/assets/javascripts/visual_review_toolbar/shared/constants.js @@ -1,14 +1,17 @@ // component selectors +const CHANGE_MR_ID_BUTTON = 'gitlab-change-mr'; const COLLAPSE_BUTTON = 'gitlab-collapse'; const COMMENT_BOX = 'gitlab-comment'; const COMMENT_BUTTON = 'gitlab-comment-button'; const FORM = 'gitlab-form'; const FORM_CONTAINER = 'gitlab-form-wrapper'; -const LOGIN = 'gitlab-login'; +const LOGIN = 'gitlab-login-button'; const LOGOUT = 'gitlab-logout-button'; +const MR_ID = 'gitlab-submit-mr'; +const MR_ID_BUTTON = 'gitlab-submit-mr-button'; const NOTE = 'gitlab-validation-note'; const NOTE_CONTAINER = 'gitlab-note-wrapper'; -const REMEMBER_TOKEN = 'gitlab-remember_token'; +const REMEMBER_ITEM = 'gitlab-remember-item'; const REVIEW_CONTAINER = 'gitlab-review-container'; const TOKEN_BOX = 'gitlab-token'; @@ -21,6 +24,7 @@ const RED = 'rgba(219, 59, 33, 1)'; const WHITE = 'rgba(250, 250, 250, 1)'; export { + CHANGE_MR_ID_BUTTON, COLLAPSE_BUTTON, COMMENT_BOX, COMMENT_BUTTON, @@ -28,9 +32,11 @@ export { FORM_CONTAINER, LOGIN, LOGOUT, + MR_ID, + MR_ID_BUTTON, NOTE, NOTE_CONTAINER, - REMEMBER_TOKEN, + REMEMBER_ITEM, REVIEW_CONTAINER, TOKEN_BOX, BLACK, diff --git a/app/assets/javascripts/visual_review_toolbar/shared/index.js b/app/assets/javascripts/visual_review_toolbar/shared/index.js new file mode 100644 index 00000000000..751eae74dde --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/shared/index.js @@ -0,0 +1,49 @@ +import { + CHANGE_MR_ID_BUTTON, + COLLAPSE_BUTTON, + COMMENT_BOX, + COMMENT_BUTTON, + FORM, + FORM_CONTAINER, + LOGIN, + LOGOUT, + MR_ID, + MR_ID_BUTTON, + NOTE, + NOTE_CONTAINER, + REMEMBER_ITEM, + REVIEW_CONTAINER, + TOKEN_BOX, + BLACK, + CLEAR, + MUTED, + RED, + WHITE, +} from './constants'; + +import { localStorage, sessionStorage } from './storage_utils'; + +export { + localStorage, + sessionStorage, + CHANGE_MR_ID_BUTTON, + COLLAPSE_BUTTON, + COMMENT_BOX, + COMMENT_BUTTON, + FORM, + FORM_CONTAINER, + LOGIN, + LOGOUT, + MR_ID, + MR_ID_BUTTON, + NOTE, + NOTE_CONTAINER, + REMEMBER_ITEM, + REVIEW_CONTAINER, + TOKEN_BOX, + BLACK, + CLEAR, + MUTED, + RED, + WHITE, +}; diff --git a/app/assets/javascripts/visual_review_toolbar/shared/storage_utils.js b/app/assets/javascripts/visual_review_toolbar/shared/storage_utils.js new file mode 100644 index 00000000000..00456d3536e --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/shared/storage_utils.js @@ -0,0 +1,42 @@ +import { setUsingGracefulStorageFlag } from '../store/state'; + +const TEST_KEY = 'gitlab-storage-test'; + +const createStorageStub = () => { + const items = {}; + + return { + getItem(key) { + return items[key]; + }, + setItem(key, value) { + items[key] = value; + }, + removeItem(key) { + delete items[key]; + }, + }; +}; + +const hasStorageSupport = storage => { + // Support test taken from https://stackoverflow.com/a/11214467/1708147 + try { + storage.setItem(TEST_KEY, TEST_KEY); + storage.removeItem(TEST_KEY); + setUsingGracefulStorageFlag(true); + + return true; + } catch (err) { + setUsingGracefulStorageFlag(false); + return false; + } +}; + +const useGracefulStorage = storage => + // If a browser does not support local storage, let's return a graceful implementation. + hasStorageSupport(storage) ? storage : createStorageStub(); + +const localStorage = useGracefulStorage(window.localStorage); +const sessionStorage = useGracefulStorage(window.sessionStorage); + +export { localStorage, sessionStorage }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/events.js b/app/assets/javascripts/visual_review_toolbar/store/events.js index 93996be8473..c9095c77ef1 100644 --- a/app/assets/javascripts/visual_review_toolbar/store/events.js +++ b/app/assets/javascripts/visual_review_toolbar/store/events.js @@ -1,20 +1,37 @@ import { + addMr, authorizeUser, + changeSelectedMr, logoutUser, postComment, + saveComment, toggleForm, +} from '../components'; + +import { + CHANGE_MR_ID_BUTTON, COLLAPSE_BUTTON, COMMENT_BUTTON, LOGIN, LOGOUT, -} from '../components'; + MR_ID_BUTTON, +} from '../shared'; import { state } from './state'; +import debounce from './utils'; const noop = () => {}; -const eventLookup = ({ target: { id } }) => { +// State needs to be bound here to be acted on +// because these are called by click events and +// as such are called with only the `event` object +const eventLookup = id => { switch (id) { + case CHANGE_MR_ID_BUTTON: + return () => { + saveComment(); + changeSelectedMr(state); + }; case COLLAPSE_BUTTON: return toggleForm; case COMMENT_BUTTON: @@ -22,7 +39,12 @@ const eventLookup = ({ target: { id } }) => { case LOGIN: return authorizeUser.bind(null, state); case LOGOUT: - return logoutUser; + return () => { + saveComment(); + logoutUser(state); + }; + case MR_ID_BUTTON: + return addMr.bind(null, state); default: return noop; } @@ -33,4 +55,19 @@ const updateWindowSize = wind => { state.innerHeight = wind.innerHeight; }; -export { eventLookup, updateWindowSize }; +const initializeGlobalListeners = () => { + window.addEventListener('resize', debounce(updateWindowSize.bind(null, window), 200)); + window.addEventListener('beforeunload', event => { + if (state.usingGracefulStorage) { + // if there is no browser storage support, reloading will lose the comment; this way, the user will be warned + // we assign the return value because it is required by Chrome see: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#Example, + event.preventDefault(); + /* eslint-disable-next-line no-param-reassign */ + event.returnValue = ''; + } + + saveComment(); + }); +}; + +export { eventLookup, initializeGlobalListeners }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/index.js b/app/assets/javascripts/visual_review_toolbar/store/index.js index 7143588c0bf..07c8dd6f1d2 100644 --- a/app/assets/javascripts/visual_review_toolbar/store/index.js +++ b/app/assets/javascripts/visual_review_toolbar/store/index.js @@ -1,5 +1,11 @@ -import { eventLookup, updateWindowSize } from './events'; -import { getInitialView, initializeState } from './state'; -import debounce from './utils'; +import { eventLookup, initializeGlobalListeners } from './events'; +import { nextView, getInitialView, initializeState, setUsingGracefulStorageFlag } from './state'; -export { debounce, eventLookup, getInitialView, initializeState, updateWindowSize }; +export { + eventLookup, + getInitialView, + initializeGlobalListeners, + initializeState, + nextView, + setUsingGracefulStorageFlag, +}; diff --git a/app/assets/javascripts/visual_review_toolbar/store/state.js b/app/assets/javascripts/visual_review_toolbar/store/state.js index 22702d524b8..741a5c7d99c 100644 --- a/app/assets/javascripts/visual_review_toolbar/store/state.js +++ b/app/assets/javascripts/visual_review_toolbar/store/state.js @@ -1,8 +1,9 @@ -import { comment, login, collapseButton } from '../components'; +import { comment, login, mrForm } from '../components'; +import { localStorage, COMMENT_BOX, LOGIN, MR_ID } from '../shared'; const state = { browser: '', - href: '', + usingGracefulStorage: '', innerWidth: '', innerHeight: '', mergeRequestId: '', @@ -23,11 +24,31 @@ const getBrowserId = sUsrAg => { return aKeys[nIdx]; }; +const nextView = (appState, form = 'none') => { + const formsList = { + [COMMENT_BOX]: currentState => (currentState.token ? mrForm : login), + [LOGIN]: currentState => (currentState.mergeRequestId ? comment(currentState) : mrForm), + [MR_ID]: currentState => (currentState.token ? comment(currentState) : login), + none: currentState => { + if (!currentState.token) { + return login; + } + + if (currentState.token && !currentState.mergeRequestId) { + return mrForm; + } + + return comment(currentState); + }, + }; + + return formsList[form](appState); +}; + const initializeState = (wind, doc) => { const { innerWidth, innerHeight, - location: { href }, navigator: { platform, userAgent }, } = wind; @@ -39,7 +60,6 @@ const initializeState = (wind, doc) => { // This mutates our default state object above. It's weird but it makes the linter happy. Object.assign(state, { browser, - href, innerWidth, innerHeight, mergeRequestId, @@ -49,30 +69,27 @@ const initializeState = (wind, doc) => { projectPath, userAgent, }); + + return state; }; -function getInitialView({ localStorage }) { - const loginView = { - content: login, - toggleButton: collapseButton, - }; +const getInitialView = () => { + const token = localStorage.getItem('token'); + const mrId = localStorage.getItem('mergeRequestId'); - const commentView = { - content: comment, - toggleButton: collapseButton, - }; - - try { - const token = localStorage.getItem('token'); - - if (token) { - state.token = token; - return commentView; - } - return loginView; - } catch (err) { - return loginView; + if (token) { + state.token = token; } -} -export { initializeState, getInitialView, state }; + if (mrId) { + state.mergeRequestId = mrId; + } + + return nextView(state); +}; + +const setUsingGracefulStorageFlag = flag => { + state.usingGracefulStorage = !flag; +}; + +export { initializeState, getInitialView, nextView, setUsingGracefulStorageFlag, state }; diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css index 6a7b2f52549..e5732fd5d93 100644 --- a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css +++ b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css @@ -107,10 +107,14 @@ } .gitlab-button-wrapper { - margin-top: 1rem; + margin-top: 0.5rem; display: flex; align-items: baseline; - justify-content: flex-end; + /* + this makes sure the hit enter to submit picks the correct button + on the comment view + */ + flex-direction: row-reverse; } .gitlab-collapse { @@ -155,6 +159,12 @@ text-decoration: underline; } +.gitlab-link-button { + border: none; + cursor: pointer; + padding: 0 .15rem; +} + .gitlab-message { padding: .25rem 0; margin: 0; @@ -165,7 +175,7 @@ font-size: .7rem; line-height: 1rem; color: #666; - margin-bottom: 0; + margin-bottom: .5rem; } .gitlab-input { diff --git a/changelogs/unreleased/64190-add-mr-form.yml b/changelogs/unreleased/64190-add-mr-form.yml new file mode 100644 index 00000000000..08340d01fd8 --- /dev/null +++ b/changelogs/unreleased/64190-add-mr-form.yml @@ -0,0 +1,5 @@ +--- +title: Add MR form to Visual Review (EE) runtime configuration +merge_request: 30481 +author: +type: changed