Commit 6bfdf529 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo Committed by Paul Slaughter

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
parent dbe3b984
import { BLACK, COMMENT_BOX, MUTED, LOGOUT } from './constants'; import { nextView } from '../store';
import { clearNote, postError } from './note'; import { localStorage, COMMENT_BOX, LOGOUT } from '../shared';
import { import { clearNote } from './note';
buttonClearStyles, import { buttonClearStyles } from './utils';
selectCommentBox, import { addForm } from './wrapper';
selectCommentButton, import { changeSelectedMr, selectedMrNote } from './comment_mr_note';
selectNote, import postComment from './comment_post';
selectNoteContainer, import { saveComment, getSavedComment } from './comment_storage';
} from './utils';
const comment = state => {
const comment = ` const savedComment = getSavedComment();
<div>
<textarea id="${COMMENT_BOX}" name="${COMMENT_BOX}" rows="3" placeholder="Enter your feedback or idea" class="gitlab-input" aria-required="true"></textarea> return `
<p class="gitlab-metadata-note">Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.</p> <div>
</div> <textarea id="${COMMENT_BOX}" name="${COMMENT_BOX}" rows="3" placeholder="Enter your feedback or idea" class="gitlab-input" aria-required="true">${savedComment}</textarea>
<div class="gitlab-button-wrapper"> ${selectedMrNote(state)}
<button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Log out </button> <p class="gitlab-metadata-note">Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.</p>
<button class="gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="gitlab-comment-button"> Send feedback </button> </div>
</div> <div class="gitlab-button-wrapper">
`; <button class="gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="gitlab-comment-button"> Send feedback </button>
<button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Log out </button>
const resetCommentButton = () => { </div>
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
<details>
<summary>Metadata</summary>
Posted from ${href} | ${platform} | ${browser} | ${innerWidth} x ${innerHeight}.
<br /><br />
<em>User agent: ${userAgent}</em>
</details>
`; `;
};
const url = ` // This function is here becaause it is called only from the comment view
${mrUrl}/api/v4/projects/${projectId}/merge_requests/${mergeRequestId}/discussions`; // If we reach a design where we can logout from multiple views, promote this
// to it's own package
const body = `${commentText} ${detailText}`; const logoutUser = state => {
localStorage.removeItem('token');
fetch(url, { localStorage.removeItem('mergeRequestId');
method: 'POST', state.token = '';
headers: { state.mergeRequestId = '';
'PRIVATE-TOKEN': token,
'Content-Type': 'application/json', clearNote();
}, addForm(nextView(state, COMMENT_BOX));
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 <a class="gitlab-link" href="${feedbackLink}">${projectPath} #${mergeRequestId} (comment ${commentId})</a>`;
confirmAndClear(feedbackInfo);
})
.catch(err => {
postError(
`Your comment could not be sent. Please try again. Error: ${err.message}`,
COMMENT_BOX,
);
resetCommentBox();
resetCommentButton();
});
}; };
export { comment, postComment }; export { changeSelectedMr, comment, logoutUser, postComment, saveComment };
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 `
<p class="gitlab-metadata-note">
This posts to merge request <a class="gitlab-link" href="${mrLink}">!${mergeRequestId}</a>.
<button style="${buttonClearStyles}" type="button" id="${CHANGE_MR_ID_BUTTON}" class="gitlab-link gitlab-link-button">Change</button>
</p>
`;
};
const clearMrId = state => {
localStorage.removeItem('mergeRequestId');
state.mergeRequestId = '';
};
const changeSelectedMr = state => {
clearMrId(state);
clearNote();
addForm(nextView(state, COMMENT_BOX));
};
export { changeSelectedMr, selectedMrNote };
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
<details>
<summary>Metadata</summary>
Posted from ${href} | ${platform} | ${browser} | ${innerWidth} x ${innerHeight}.
<br /><br />
<em>User agent: ${userAgent}</em>
</details>
`;
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 <a class="gitlab-link" href="${feedbackLink}">${projectPath} !${mergeRequestId} (comment ${commentId})</a>`;
confirmAndClear(feedbackInfo);
})
.catch(err => {
postError(commentErrors(err), COMMENT_BOX);
resetCommentBox();
resetCommentButton();
});
};
export default postComment;
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 };
import { REMEMBER_ITEM } from '../shared';
import { buttonClearStyles } from './utils';
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
const rememberBox = (rememberText = 'Remember me') => `
<div class="gitlab-checkbox-wrapper">
<input type="checkbox" id="${REMEMBER_ITEM}" name="${REMEMBER_ITEM}" value="remember">
<label for="${REMEMBER_ITEM}" class="gitlab-checkbox-label">${rememberText}</label>
</div>
`;
const submitButton = buttonId => `
<div class="gitlab-button-wrapper">
<button class="gitlab-button-wide gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="${buttonId}"> Submit </button>
</div>
`;
export { rememberBox, submitButton };
import { comment, postComment } from './comment'; import { changeSelectedMr, comment, logoutUser, postComment, saveComment } from './comment';
import {
COLLAPSE_BUTTON,
COMMENT_BUTTON,
FORM_CONTAINER,
LOGIN,
LOGOUT,
REVIEW_CONTAINER,
} from './constants';
import { authorizeUser, login } from './login'; import { authorizeUser, login } from './login';
import { addMr, mrForm } from './mr_id';
import { note } from './note'; import { note } from './note';
import { selectContainer } from './utils'; import { selectContainer, selectForm } from './utils';
import { buttonAndForm, logoutUser, toggleForm } from './wrapper'; import { buttonAndForm, toggleForm } from './wrapper';
import { collapseButton } from './wrapper_icons';
export { export {
addMr,
authorizeUser, authorizeUser,
buttonAndForm, buttonAndForm,
collapseButton, changeSelectedMr,
comment, comment,
login, login,
logoutUser, logoutUser,
mrForm,
note, note,
postComment, postComment,
saveComment,
selectContainer, selectContainer,
selectForm,
toggleForm, toggleForm,
COLLAPSE_BUTTON,
COMMENT_BUTTON,
FORM_CONTAINER,
LOGIN,
LOGOUT,
REVIEW_CONTAINER,
}; };
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 { clearNote, postError } from './note';
import { buttonClearStyles, selectRemember, selectToken } from './utils'; import { rememberBox, submitButton } from './form_elements';
import { addCommentForm } from './wrapper'; import { selectRemember, selectToken } from './utils';
import { addForm } from './wrapper';
const labelText = `
Enter your <a class="gitlab-link" href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html">personal access token</a>
`;
const login = ` const login = `
<div> <div>
<label for="${TOKEN_BOX}" class="gitlab-label">Enter your <a class="gitlab-link" href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html">personal access token</a></label> <label for="${TOKEN_BOX}" class="gitlab-label">${labelText}</label>
<input class="gitlab-input" type="password" id="${TOKEN_BOX}" name="${TOKEN_BOX}" aria-required="true" autocomplete="current-password"> <input class="gitlab-input" type="password" id="${TOKEN_BOX}" name="${TOKEN_BOX}" autocomplete="current-password" aria-required="true">
</div> </div>
<div class="gitlab-checkbox-wrapper"> ${rememberBox()}
<input type="checkbox" id="${REMEMBER_TOKEN}" name="${REMEMBER_TOKEN}" value="remember"> ${submitButton(LOGIN)}
<label for="${REMEMBER_TOKEN}" class="gitlab-checkbox-label">Remember me</label>
</div>
<div class="gitlab-button-wrapper">
<button class="gitlab-button-wide gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="${LOGIN}"> Submit </button>
</div>
`; `;
const storeToken = (token, state) => { const storeToken = (token, state) => {
const { localStorage } = window;
const rememberMe = selectRemember().checked; const rememberMe = selectRemember().checked;
// All the browsers we support have localStorage, so let's silently fail if (rememberMe) {
// and go on with the rest of the functionality. localStorage.setItem('token', token);
try {
if (rememberMe) {
localStorage.setItem('token', token);
}
} finally {
state.token = token;
} }
state.token = token;
}; };
const authorizeUser = state => { const authorizeUser = state => {
...@@ -45,7 +41,7 @@ const authorizeUser = state => { ...@@ -45,7 +41,7 @@ const authorizeUser = state => {
} }
storeToken(token, state); storeToken(token, state);
addCommentForm(); addForm(nextView(state, LOGIN));
}; };
export { authorizeUser, login }; export { authorizeUser, login, storeToken };
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 = `
<div>
<label for="${MR_ID}" class="gitlab-label">${mrLabel}</label>
<input class="gitlab-input" type="text" pattern="[1-9][0-9]*" id="${MR_ID}" name="${MR_ID}" placeholder="e.g., 321" aria-required="true">
</div>
${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 };
import { NOTE, NOTE_CONTAINER, RED } from './constants'; import { NOTE, NOTE_CONTAINER, RED } from '../shared';
import { selectById, selectNote, selectNoteContainer } from './utils'; import { selectById, selectNote, selectNoteContainer } from './utils';
const note = ` const note = `
......
...@@ -6,12 +6,13 @@ import { ...@@ -6,12 +6,13 @@ import {
COMMENT_BUTTON, COMMENT_BUTTON,
FORM, FORM,
FORM_CONTAINER, FORM_CONTAINER,
MR_ID,
NOTE, NOTE,
NOTE_CONTAINER, NOTE_CONTAINER,
REMEMBER_TOKEN, REMEMBER_ITEM,
REVIEW_CONTAINER, REVIEW_CONTAINER,
TOKEN_BOX, TOKEN_BOX,
} from './constants'; } from '../shared';
// this style must be applied inline in a handful of components // this style must be applied inline in a handful of components
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
...@@ -27,9 +28,10 @@ const selectCommentButton = () => document.getElementById(COMMENT_BUTTON); ...@@ -27,9 +28,10 @@ const selectCommentButton = () => document.getElementById(COMMENT_BUTTON);
const selectContainer = () => document.getElementById(REVIEW_CONTAINER); const selectContainer = () => document.getElementById(REVIEW_CONTAINER);
const selectForm = () => document.getElementById(FORM); const selectForm = () => document.getElementById(FORM);
const selectFormContainer = () => document.getElementById(FORM_CONTAINER); const selectFormContainer = () => document.getElementById(FORM_CONTAINER);
const selectMrBox = () => document.getElementById(MR_ID);
const selectNote = () => document.getElementById(NOTE); const selectNote = () => document.getElementById(NOTE);
const selectNoteContainer = () => document.getElementById(NOTE_CONTAINER); const selectNoteContainer = () => document.getElementById(NOTE_CONTAINER);
const selectRemember = () => document.getElementById(REMEMBER_TOKEN); const selectRemember = () => document.getElementById(REMEMBER_ITEM);
const selectToken = () => document.getElementById(TOKEN_BOX); const selectToken = () => document.getElementById(TOKEN_BOX);
export { export {
...@@ -41,6 +43,7 @@ export { ...@@ -41,6 +43,7 @@ export {
selectCommentButton, selectCommentButton,
selectForm, selectForm,
selectFormContainer, selectFormContainer,
selectMrBox,
selectNote, selectNote,
selectNoteContainer, selectNoteContainer,
selectRemember, selectRemember,
......
import { comment } from './comment'; import { CLEAR, FORM, FORM_CONTAINER, WHITE } from '../shared';
import { CLEAR, FORM, FORM_CONTAINER, WHITE } from './constants';
import { login } from './login';
import { clearNote } from './note';
import { import {
selectCollapseButton, selectCollapseButton,
selectForm, selectForm,
selectFormContainer, selectFormContainer,
selectNoteContainer, selectNoteContainer,
} from './utils'; } from './utils';
import { commentIcon, compressIcon } from './wrapper_icons'; import { collapseButton, commentIcon, compressIcon } from './wrapper_icons';
const form = content => ` const form = content => `
<form id="${FORM}"> <form id="${FORM}" novalidate>
${content} ${content}
</form> </form>
`; `;
const buttonAndForm = ({ content, toggleButton }) => ` const buttonAndForm = content => `
<div id="${FORM_CONTAINER}" class="gitlab-form-open"> <div id="${FORM_CONTAINER}" class="gitlab-form-open">
${toggleButton} ${collapseButton}
${form(content)} ${form(content)}
</div> </div>
`; `;
const addCommentForm = () => { const addForm = nextForm => {
const formWrapper = selectForm(); 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() { function toggleForm() {
const collapseButton = selectCollapseButton(); const toggleButton = selectCollapseButton();
const currentForm = selectForm(); const currentForm = selectForm();
const formContainer = selectFormContainer(); const formContainer = selectFormContainer();
const noteContainer = selectNoteContainer(); const noteContainer = selectNoteContainer();
...@@ -84,19 +61,19 @@ function toggleForm() { ...@@ -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]; const currentVals = stateVals[nextState];
formContainer.classList.replace(...currentVals.containerClasses); formContainer.classList.replace(...currentVals.containerClasses);
formContainer.style.backgroundColor = currentVals.backgroundColor; formContainer.style.backgroundColor = currentVals.backgroundColor;
formContainer.classList.toggle('gitlab-form-open'); formContainer.classList.toggle('gitlab-form-open');
currentForm.style.display = currentVals.display; currentForm.style.display = currentVals.display;
collapseButton.classList.replace(...currentVals.buttonClasses); toggleButton.classList.replace(...currentVals.buttonClasses);
collapseButton.innerHTML = currentVals.icon; toggleButton.innerHTML = currentVals.icon;
if (noteContainer && noteContainer.innerText.length > 0) { if (noteContainer && noteContainer.innerText.length > 0) {
noteContainer.style.display = currentVals.display; noteContainer.style.display = currentVals.display;
} }
} }
export { addCommentForm, addLoginForm, buttonAndForm, logoutUser, toggleForm }; export { addForm, buttonAndForm, toggleForm };
import './styles/toolbar.css'; import './styles/toolbar.css';
import { buttonAndForm, note, selectContainer, REVIEW_CONTAINER } from './components'; import { buttonAndForm, note, selectForm, selectContainer } from './components';
import { debounce, eventLookup, getInitialView, initializeState, updateWindowSize } from './store'; import { REVIEW_CONTAINER } from './shared';
import { eventLookup, getInitialView, initializeGlobalListeners, initializeState } from './store';
/* /*
...@@ -20,7 +21,7 @@ import { debounce, eventLookup, getInitialView, initializeState, updateWindowSiz ...@@ -20,7 +21,7 @@ import { debounce, eventLookup, getInitialView, initializeState, updateWindowSiz
window.addEventListener('load', () => { window.addEventListener('load', () => {
initializeState(window, document); initializeState(window, document);
const mainContent = buttonAndForm(getInitialView(window)); const mainContent = buttonAndForm(getInitialView());
const container = document.createElement('div'); const container = document.createElement('div');
container.setAttribute('id', REVIEW_CONTAINER); container.setAttribute('id', REVIEW_CONTAINER);
container.insertAdjacentHTML('beforeend', note); container.insertAdjacentHTML('beforeend', note);
...@@ -29,8 +30,22 @@ window.addEventListener('load', () => { ...@@ -29,8 +30,22 @@ window.addEventListener('load', () => {
document.body.insertBefore(container, document.body.firstChild); document.body.insertBefore(container, document.body.firstChild);
selectContainer().addEventListener('click', event => { 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();
}); });
// component selectors // component selectors
const CHANGE_MR_ID_BUTTON = 'gitlab-change-mr';
const COLLAPSE_BUTTON = 'gitlab-collapse'; const COLLAPSE_BUTTON = 'gitlab-collapse';
const COMMENT_BOX = 'gitlab-comment'; const COMMENT_BOX = 'gitlab-comment';
const COMMENT_BUTTON = 'gitlab-comment-button'; const COMMENT_BUTTON = 'gitlab-comment-button';
const FORM = 'gitlab-form'; const FORM = 'gitlab-form';
const FORM_CONTAINER = 'gitlab-form-wrapper'; const FORM_CONTAINER = 'gitlab-form-wrapper';
const LOGIN = 'gitlab-login'; const LOGIN = 'gitlab-login-button';
const LOGOUT = 'gitlab-logout-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 = 'gitlab-validation-note';
const NOTE_CONTAINER = 'gitlab-note-wrapper'; 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 REVIEW_CONTAINER = 'gitlab-review-container';
const TOKEN_BOX = 'gitlab-token'; const TOKEN_BOX = 'gitlab-token';
...@@ -21,6 +24,7 @@ const RED = 'rgba(219, 59, 33, 1)'; ...@@ -21,6 +24,7 @@ const RED = 'rgba(219, 59, 33, 1)';
const WHITE = 'rgba(250, 250, 250, 1)'; const WHITE = 'rgba(250, 250, 250, 1)';
export { export {
CHANGE_MR_ID_BUTTON,
COLLAPSE_BUTTON, COLLAPSE_BUTTON,
COMMENT_BOX, COMMENT_BOX,
COMMENT_BUTTON, COMMENT_BUTTON,
...@@ -28,9 +32,11 @@ export { ...@@ -28,9 +32,11 @@ export {
FORM_CONTAINER, FORM_CONTAINER,
LOGIN, LOGIN,
LOGOUT, LOGOUT,
MR_ID,
MR_ID_BUTTON,
NOTE, NOTE,
NOTE_CONTAINER, NOTE_CONTAINER,
REMEMBER_TOKEN, REMEMBER_ITEM,
REVIEW_CONTAINER, REVIEW_CONTAINER,
TOKEN_BOX, TOKEN_BOX,
BLACK, BLACK,
......
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,
};
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 };
import { import {
addMr,
authorizeUser, authorizeUser,
changeSelectedMr,
logoutUser, logoutUser,
postComment, postComment,
saveComment,
toggleForm, toggleForm,
} from '../components';
import {
CHANGE_MR_ID_BUTTON,
COLLAPSE_BUTTON, COLLAPSE_BUTTON,
COMMENT_BUTTON, COMMENT_BUTTON,
LOGIN, LOGIN,
LOGOUT, LOGOUT,
} from '../components'; MR_ID_BUTTON,
} from '../shared';
import { state } from './state'; import { state } from './state';
import debounce from './utils';
const noop = () => {}; 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) { switch (id) {
case CHANGE_MR_ID_BUTTON:
return () => {
saveComment();
changeSelectedMr(state);
};
case COLLAPSE_BUTTON: case COLLAPSE_BUTTON:
return toggleForm; return toggleForm;
case COMMENT_BUTTON: case COMMENT_BUTTON:
...@@ -22,7 +39,12 @@ const eventLookup = ({ target: { id } }) => { ...@@ -22,7 +39,12 @@ const eventLookup = ({ target: { id } }) => {
case LOGIN: case LOGIN:
return authorizeUser.bind(null, state); return authorizeUser.bind(null, state);
case LOGOUT: case LOGOUT:
return logoutUser; return () => {
saveComment();
logoutUser(state);
};
case MR_ID_BUTTON:
return addMr.bind(null, state);
default: default:
return noop; return noop;
} }
...@@ -33,4 +55,19 @@ const updateWindowSize = wind => { ...@@ -33,4 +55,19 @@ const updateWindowSize = wind => {
state.innerHeight = wind.innerHeight; 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 };
import { eventLookup, updateWindowSize } from './events'; import { eventLookup, initializeGlobalListeners } from './events';
import { getInitialView, initializeState } from './state'; import { nextView, getInitialView, initializeState, setUsingGracefulStorageFlag } from './state';
import debounce from './utils';
export { debounce, eventLookup, getInitialView, initializeState, updateWindowSize }; export {
eventLookup,
getInitialView,
initializeGlobalListeners,
initializeState,
nextView,
setUsingGracefulStorageFlag,
};
import { comment, login, collapseButton } from '../components'; import { comment, login, mrForm } from '../components';
import { localStorage, COMMENT_BOX, LOGIN, MR_ID } from '../shared';
const state = { const state = {
browser: '', browser: '',
href: '', usingGracefulStorage: '',
innerWidth: '', innerWidth: '',
innerHeight: '', innerHeight: '',
mergeRequestId: '', mergeRequestId: '',
...@@ -23,11 +24,31 @@ const getBrowserId = sUsrAg => { ...@@ -23,11 +24,31 @@ const getBrowserId = sUsrAg => {
return aKeys[nIdx]; 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 initializeState = (wind, doc) => {
const { const {
innerWidth, innerWidth,
innerHeight, innerHeight,
location: { href },
navigator: { platform, userAgent }, navigator: { platform, userAgent },
} = wind; } = wind;
...@@ -39,7 +60,6 @@ const initializeState = (wind, doc) => { ...@@ -39,7 +60,6 @@ const initializeState = (wind, doc) => {
// This mutates our default state object above. It's weird but it makes the linter happy. // This mutates our default state object above. It's weird but it makes the linter happy.
Object.assign(state, { Object.assign(state, {
browser, browser,
href,
innerWidth, innerWidth,
innerHeight, innerHeight,
mergeRequestId, mergeRequestId,
...@@ -49,30 +69,27 @@ const initializeState = (wind, doc) => { ...@@ -49,30 +69,27 @@ const initializeState = (wind, doc) => {
projectPath, projectPath,
userAgent, userAgent,
}); });
};
function getInitialView({ localStorage }) { return state;
const loginView = { };
content: login,
toggleButton: collapseButton,
};
const commentView = { const getInitialView = () => {
content: comment, const token = localStorage.getItem('token');
toggleButton: collapseButton, const mrId = localStorage.getItem('mergeRequestId');
};
try { if (token) {
const token = localStorage.getItem('token'); state.token = token;
}
if (token) { if (mrId) {
state.token = token; state.mergeRequestId = mrId;
return commentView;
}
return loginView;
} catch (err) {
return loginView;
} }
}
export { initializeState, getInitialView, state }; return nextView(state);
};
const setUsingGracefulStorageFlag = flag => {
state.usingGracefulStorage = !flag;
};
export { initializeState, getInitialView, nextView, setUsingGracefulStorageFlag, state };
...@@ -107,10 +107,14 @@ ...@@ -107,10 +107,14 @@
} }
.gitlab-button-wrapper { .gitlab-button-wrapper {
margin-top: 1rem; margin-top: 0.5rem;
display: flex; display: flex;
align-items: baseline; 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 { .gitlab-collapse {
...@@ -155,6 +159,12 @@ ...@@ -155,6 +159,12 @@
text-decoration: underline; text-decoration: underline;
} }
.gitlab-link-button {
border: none;
cursor: pointer;
padding: 0 .15rem;
}
.gitlab-message { .gitlab-message {
padding: .25rem 0; padding: .25rem 0;
margin: 0; margin: 0;
...@@ -165,7 +175,7 @@ ...@@ -165,7 +175,7 @@
font-size: .7rem; font-size: .7rem;
line-height: 1rem; line-height: 1rem;
color: #666; color: #666;
margin-bottom: 0; margin-bottom: .5rem;
} }
.gitlab-input { .gitlab-input {
......
---
title: Add MR form to Visual Review (EE) runtime configuration
merge_request: 30481
author:
type: changed
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment