summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/notes/stores
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/notes/stores')
-rw-r--r--app/assets/javascripts/notes/stores/actions.js217
-rw-r--r--app/assets/javascripts/notes/stores/getters.js31
-rw-r--r--app/assets/javascripts/notes/stores/index.js23
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js14
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js151
-rw-r--r--app/assets/javascripts/notes/stores/utils.js31
6 files changed, 467 insertions, 0 deletions
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
new file mode 100644
index 00000000000..13cd74bfa1c
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -0,0 +1,217 @@
+/* global Flash */
+import Visibility from 'visibilityjs';
+import Poll from '../../lib/utils/poll';
+import * as types from './mutation_types';
+import * as utils from './utils';
+import * as constants from '../constants';
+import service from '../services/issue_notes_service';
+import loadAwardsHandler from '../../awards_handler';
+import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
+
+let eTagPoll;
+
+export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data);
+export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data);
+export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data);
+export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
+export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data);
+export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data);
+export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
+
+export const fetchNotes = ({ commit }, path) => service
+ .fetchNotes(path)
+ .then(res => res.json())
+ .then((res) => {
+ commit(types.SET_INITIAL_NOTES, res);
+ });
+
+export const deleteNote = ({ commit }, note) => service
+ .deleteNote(note.path)
+ .then(() => {
+ commit(types.DELETE_NOTE, note);
+ });
+
+export const updateNote = ({ commit }, { endpoint, note }) => service
+ .updateNote(endpoint, note)
+ .then(res => res.json())
+ .then((res) => {
+ commit(types.UPDATE_NOTE, res);
+ });
+
+export const replyToDiscussion = ({ commit }, { endpoint, data }) => service
+ .replyToDiscussion(endpoint, data)
+ .then(res => res.json())
+ .then((res) => {
+ commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
+
+ return res;
+ });
+
+export const createNewNote = ({ commit }, { endpoint, data }) => service
+ .createNewNote(endpoint, data)
+ .then(res => res.json())
+ .then((res) => {
+ if (!res.errors) {
+ commit(types.ADD_NEW_NOTE, res);
+ }
+ return res;
+ });
+
+export const removePlaceholderNotes = ({ commit }) =>
+ commit(types.REMOVE_PLACEHOLDER_NOTES);
+
+export const saveNote = ({ commit, dispatch }, noteData) => {
+ const { note } = noteData.data.note;
+ let placeholderText = note;
+ const hasQuickActions = utils.hasQuickActions(placeholderText);
+ const replyId = noteData.data.in_reply_to_discussion_id;
+ const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote';
+
+ commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders
+ $('.notes-form .flash-container').hide(); // hide previous flash notification
+
+ if (hasQuickActions) {
+ placeholderText = utils.stripQuickActions(placeholderText);
+ }
+
+ if (placeholderText.length) {
+ commit(types.SHOW_PLACEHOLDER_NOTE, {
+ noteBody: placeholderText,
+ replyId,
+ });
+ }
+
+ if (hasQuickActions) {
+ commit(types.SHOW_PLACEHOLDER_NOTE, {
+ isSystemNote: true,
+ noteBody: utils.getQuickActionText(note),
+ replyId,
+ });
+ }
+
+ return dispatch(methodToDispatch, noteData)
+ .then((res) => {
+ const { errors } = res;
+ const commandsChanges = res.commands_changes;
+
+ if (hasQuickActions && errors && Object.keys(errors).length) {
+ eTagPoll.makeRequest();
+
+ $('.js-gfm-input').trigger('clear-commands-cache.atwho');
+ Flash('Commands applied', 'notice', $(noteData.flashContainer));
+ }
+
+ if (commandsChanges) {
+ if (commandsChanges.emoji_award) {
+ const votesBlock = $('.js-awards-block').eq(0);
+
+ loadAwardsHandler()
+ .then((awardsHandler) => {
+ awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award);
+ awardsHandler.scrollToAwards();
+ })
+ .catch(() => {
+ Flash(
+ 'Something went wrong while adding your award. Please try again.',
+ null,
+ $(noteData.flashContainer),
+ );
+ });
+ }
+
+ if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) {
+ sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
+ }
+ }
+
+ if (errors && errors.commands_only) {
+ Flash(errors.commands_only, 'notice', $(noteData.flashContainer));
+ }
+ commit(types.REMOVE_PLACEHOLDER_NOTES);
+
+ return res;
+ });
+};
+
+const pollSuccessCallBack = (resp, commit, state, getters) => {
+ if (resp.notes && resp.notes.length) {
+ const { notesById } = getters;
+
+ resp.notes.forEach((note) => {
+ if (notesById[note.id]) {
+ commit(types.UPDATE_NOTE, note);
+ } else if (note.type === constants.DISCUSSION_NOTE) {
+ const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (discussion) {
+ commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
+ } else {
+ commit(types.ADD_NEW_NOTE, note);
+ }
+ } else {
+ commit(types.ADD_NEW_NOTE, note);
+ }
+ });
+ }
+
+ commit(types.SET_LAST_FETCHED_AT, resp.lastFetchedAt);
+
+ return resp;
+};
+
+export const poll = ({ commit, state, getters }) => {
+ const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
+
+ eTagPoll = new Poll({
+ resource: service,
+ method: 'poll',
+ data: requestData,
+ successCallback: resp => resp.json()
+ .then(data => pollSuccessCallBack(data, commit, state, getters)),
+ errorCallback: () => Flash('Something went wrong while fetching latest comments.'),
+ });
+
+ if (!Visibility.hidden()) {
+ eTagPoll.makeRequest();
+ } else {
+ service.poll(requestData);
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ eTagPoll.restart();
+ } else {
+ eTagPoll.stop();
+ }
+ });
+};
+
+export const fetchData = ({ commit, state, getters }) => {
+ const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
+
+ service.poll(requestData)
+ .then(resp => resp.json)
+ .then(data => pollSuccessCallBack(data, commit, state, getters))
+ .catch(() => Flash('Something went wrong while fetching latest comments.'));
+};
+
+export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => {
+ commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] });
+};
+
+export const toggleAwardRequest = ({ commit, getters, dispatch }, data) => {
+ const { endpoint, awardName } = data;
+
+ return service
+ .toggleAward(endpoint, { name: awardName })
+ .then(res => res.json())
+ .then(() => {
+ dispatch('toggleAward', data);
+ });
+};
+
+export const scrollToNoteIfNeeded = (context, el) => {
+ if (!gl.utils.isInViewport(el[0])) {
+ gl.utils.scrollToElement(el);
+ }
+};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
new file mode 100644
index 00000000000..1f0c6af6156
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -0,0 +1,31 @@
+import _ from 'underscore';
+
+export const notes = state => state.notes;
+export const targetNoteHash = state => state.targetNoteHash;
+
+export const getNotesData = state => state.notesData;
+export const getNotesDataByProp = state => prop => state.notesData[prop];
+
+export const getIssueData = state => state.issueData;
+export const getIssueDataByProp = state => prop => state.issueData[prop];
+
+export const getUserData = state => state.userData || {};
+export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
+
+export const notesById = state => state.notes.reduce((acc, note) => {
+ note.notes.every(n => Object.assign(acc, { [n.id]: n }));
+ return acc;
+}, {});
+
+const reverseNotes = array => array.slice(0).reverse();
+const isLastNote = (note, state) => !note.system &&
+ state.userData && note.author &&
+ note.author.id === state.userData.id;
+
+export const getCurrentUserLastNote = state => _.flatten(
+ reverseNotes(state.notes)
+ .map(note => reverseNotes(note.notes)),
+ ).find(el => isLastNote(el, state));
+
+export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
+ .find(el => isLastNote(el, state));
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
new file mode 100644
index 00000000000..8e0c8531bbc
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: {
+ notes: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+
+ // holds endpoints and permissions provided through haml
+ notesData: {},
+ userData: {},
+ issueData: {},
+ },
+ actions,
+ getters,
+ mutations,
+});
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
new file mode 100644
index 00000000000..cd71533ba9d
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -0,0 +1,14 @@
+export const ADD_NEW_NOTE = 'ADD_NEW_NOTE';
+export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION';
+export const DELETE_NOTE = 'DELETE_NOTE';
+export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES';
+export const SET_NOTES_DATA = 'SET_NOTES_DATA';
+export const SET_ISSUE_DATA = 'SET_ISSUE_DATA';
+export const SET_USER_DATA = 'SET_USER_DATA';
+export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES';
+export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT';
+export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH';
+export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
+export const TOGGLE_AWARD = 'TOGGLE_AWARD';
+export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
+export const UPDATE_NOTE = 'UPDATE_NOTE';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
new file mode 100644
index 00000000000..3b2b2089d6e
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -0,0 +1,151 @@
+import * as utils from './utils';
+import * as types from './mutation_types';
+import * as constants from '../constants';
+
+export default {
+ [types.ADD_NEW_NOTE](state, note) {
+ const { discussion_id, type } = note;
+ const noteData = {
+ expanded: true,
+ id: discussion_id,
+ individual_note: !(type === constants.DISCUSSION_NOTE),
+ notes: [note],
+ reply_id: discussion_id,
+ };
+
+ state.notes.push(noteData);
+ },
+
+ [types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) {
+ const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (noteObj) {
+ noteObj.notes.push(note);
+ }
+ },
+
+ [types.DELETE_NOTE](state, note) {
+ const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (noteObj.individual_note) {
+ state.notes.splice(state.notes.indexOf(noteObj), 1);
+ } else {
+ const comment = utils.findNoteObjectById(noteObj.notes, note.id);
+ noteObj.notes.splice(noteObj.notes.indexOf(comment), 1);
+
+ if (!noteObj.notes.length) {
+ state.notes.splice(state.notes.indexOf(noteObj), 1);
+ }
+ }
+ },
+
+ [types.REMOVE_PLACEHOLDER_NOTES](state) {
+ const { notes } = state;
+
+ for (let i = notes.length - 1; i >= 0; i -= 1) {
+ const note = notes[i];
+ const children = note.notes;
+
+ if (children.length && !note.individual_note) { // remove placeholder from discussions
+ for (let j = children.length - 1; j >= 0; j -= 1) {
+ if (children[j].isPlaceholderNote) {
+ children.splice(j, 1);
+ }
+ }
+ } else if (note.isPlaceholderNote) { // remove placeholders from state root
+ notes.splice(i, 1);
+ }
+ }
+ },
+
+ [types.SET_NOTES_DATA](state, data) {
+ Object.assign(state, { notesData: data });
+ },
+
+ [types.SET_ISSUE_DATA](state, data) {
+ Object.assign(state, { issueData: data });
+ },
+
+ [types.SET_USER_DATA](state, data) {
+ Object.assign(state, { userData: data });
+ },
+ [types.SET_INITIAL_NOTES](state, notesData) {
+ const notes = [];
+
+ notesData.forEach((note) => {
+ // To support legacy notes, should be very rare case.
+ if (note.individual_note && note.notes.length > 1) {
+ note.notes.forEach((n) => {
+ const nn = Object.assign({}, note);
+ nn.notes = [n]; // override notes array to only have one item to mimick individual_note
+ notes.push(nn);
+ });
+ } else {
+ notes.push(note);
+ }
+ });
+
+ Object.assign(state, { notes });
+ },
+
+ [types.SET_LAST_FETCHED_AT](state, fetchedAt) {
+ Object.assign(state, { lastFetchedAt: fetchedAt });
+ },
+
+ [types.SET_TARGET_NOTE_HASH](state, hash) {
+ Object.assign(state, { targetNoteHash: hash });
+ },
+
+ [types.SHOW_PLACEHOLDER_NOTE](state, data) {
+ let notesArr = state.notes;
+ if (data.replyId) {
+ notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes;
+ }
+
+ notesArr.push({
+ individual_note: true,
+ isPlaceholderNote: true,
+ placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE,
+ notes: [
+ {
+ body: data.noteBody,
+ },
+ ],
+ });
+ },
+
+ [types.TOGGLE_AWARD](state, data) {
+ const { awardName, note } = data;
+ const { id, name, username } = state.userData;
+
+ const hasEmojiAwardedByCurrentUser = note.award_emoji
+ .filter(emoji => emoji.name === data.awardName && emoji.user.id === id);
+
+ if (hasEmojiAwardedByCurrentUser.length) {
+ // If current user has awarded this emoji, remove it.
+ note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1);
+ } else {
+ note.award_emoji.push({
+ name: awardName,
+ user: { id, name, username },
+ });
+ }
+ },
+
+ [types.TOGGLE_DISCUSSION](state, { discussionId }) {
+ const discussion = utils.findNoteObjectById(state.notes, discussionId);
+
+ discussion.expanded = !discussion.expanded;
+ },
+
+ [types.UPDATE_NOTE](state, note) {
+ const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
+
+ if (noteObj.individual_note) {
+ noteObj.notes.splice(0, 1, note);
+ } else {
+ const comment = utils.findNoteObjectById(noteObj.notes, note.id);
+ noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
+ }
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
new file mode 100644
index 00000000000..6074115e855
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -0,0 +1,31 @@
+import AjaxCache from '~/lib/utils/ajax_cache';
+
+const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
+
+export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0];
+
+export const getQuickActionText = (note) => {
+ let text = 'Applying command';
+ const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
+
+ const executedCommands = quickActions.filter((command) => {
+ const commandRegex = new RegExp(`/${command.name}`);
+ return commandRegex.test(note);
+ });
+
+ if (executedCommands && executedCommands.length) {
+ if (executedCommands.length > 1) {
+ text = 'Applying multiple commands';
+ } else {
+ const commandDescription = executedCommands[0].description.toLowerCase();
+ text = `Applying command to ${commandDescription}`;
+ }
+ }
+
+ return text;
+};
+
+export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
+
+export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
+