Commit 9d191e8c authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'nmezzopera-refactor-data-layer-of-merge-conflicts-app' into 'master'

Add new vuex store for merge conflicts

See merge request gitlab-org/gitlab!55524
parents 2ab41de9 4d482938
import { s__ } from '~/locale';
export const CONFLICT_TYPES = {
TEXT: 'text',
TEXT_EDITOR: 'text-editor',
};
export const VIEW_TYPES = {
INLINE: 'inline',
PARALLEL: 'parallel',
};
export const EDIT_RESOLVE_MODE = 'edit';
export const INTERACTIVE_RESOLVE_MODE = 'interactive';
export const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
export const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
export const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
export const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours');
export const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs');
import Cookies from 'js-cookie';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '../constants';
import { decorateFiles, restoreFileLinesState, markLine } from '../utils';
import * as types from './mutation_types';
export const fetchConflictsData = async ({ commit, dispatch }, conflictsPath) => {
commit(types.SET_LOADING_STATE, true);
try {
const { data } = await axios.get(conflictsPath);
if (data.type === 'error') {
commit(types.SET_FAILED_REQUEST, data.message);
} else {
dispatch('setConflictsData', data);
}
} catch (e) {
commit(types.SET_FAILED_REQUEST);
}
commit(types.SET_LOADING_STATE, false);
};
export const setConflictsData = async ({ commit }, data) => {
const files = decorateFiles(data.files);
commit(types.SET_CONFLICTS_DATA, { ...data, files });
};
export const submitResolvedConflicts = async ({ commit, getters }, resolveConflictsPath) => {
commit(types.SET_SUBMIT_STATE, true);
try {
const { data } = await axios.post(resolveConflictsPath, getters.getCommitData);
window.location.assign(data.redirect_to);
} catch (e) {
commit(types.SET_SUBMIT_STATE, false);
createFlash({ message: __('Failed to save merge conflicts resolutions. Please try again!') });
}
};
export const setLoadingState = ({ commit }, isLoading) => {
commit(types.SET_LOADING_STATE, isLoading);
};
export const setErrorState = ({ commit }, hasError) => {
commit(types.SET_ERROR_STATE, hasError);
};
export const setFailedRequest = ({ commit }, message) => {
commit(types.SET_FAILED_REQUEST, message);
};
export const setViewType = ({ commit }, viewType) => {
commit(types.SET_VIEW_TYPE, viewType);
Cookies.set('diff_view', viewType);
};
export const setSubmitState = ({ commit }, isSubmitting) => {
commit(types.SET_SUBMIT_STATE, isSubmitting);
};
export const updateCommitMessage = ({ commit }, commitMessage) => {
commit(types.UPDATE_CONFLICTS_DATA, { commitMessage });
};
export const setFileResolveMode = ({ commit, state, getters }, { file, mode }) => {
const index = getters.getFileIndex(file);
const updated = { ...state.conflictsData.files[index] };
if (mode === INTERACTIVE_RESOLVE_MODE) {
updated.showEditor = false;
} else if (mode === EDIT_RESOLVE_MODE) {
// Restore Interactive mode when switching to Edit mode
updated.showEditor = true;
updated.loadEditor = true;
updated.resolutionData = {};
const { inlineLines, parallelLines } = restoreFileLinesState(updated);
updated.parallelLines = parallelLines;
updated.inlineLines = inlineLines;
}
updated.resolveMode = mode;
commit(types.UPDATE_FILE, { file: updated, index });
};
export const setPromptConfirmationState = (
{ commit, state, getters },
{ file, promptDiscardConfirmation },
) => {
const index = getters.getFileIndex(file);
const updated = { ...state.conflictsData.files[index], promptDiscardConfirmation };
commit(types.UPDATE_FILE, { file: updated, index });
};
export const handleSelected = ({ commit, state, getters }, { file, line: { id, section } }) => {
const index = getters.getFileIndex(file);
const updated = { ...state.conflictsData.files[index] };
updated.resolutionData = { ...updated.resolutionData, [id]: section };
updated.inlineLines = file.inlineLines.map((line) => {
if (id === line.id && (line.hasConflict || line.isHeader)) {
return markLine(line, section);
}
return line;
});
updated.parallelLines = file.parallelLines.map((lines) => {
let left = { ...lines[0] };
let right = { ...lines[1] };
const hasSameId = right.id === id || left.id === id;
const isLeftMatch = left.hasConflict || left.isHeader;
const isRightMatch = right.hasConflict || right.isHeader;
if (hasSameId && (isLeftMatch || isRightMatch)) {
left = markLine(left, section);
right = markLine(right, section);
}
return [left, right];
});
commit(types.UPDATE_FILE, { file: updated, index });
};
import { s__ } from '~/locale';
import { CONFLICT_TYPES, EDIT_RESOLVE_MODE, INTERACTIVE_RESOLVE_MODE } from '../constants';
export const getConflictsCount = (state) => {
if (!state.conflictsData.files.length) {
return 0;
}
const { files } = state.conflictsData;
let count = 0;
files.forEach((file) => {
if (file.type === CONFLICT_TYPES.TEXT) {
file.sections.forEach((section) => {
if (section.conflict) {
count += 1;
}
});
} else {
count += 1;
}
});
return count;
};
export const getConflictsCountText = (state, getters) => {
const count = getters.getConflictsCount;
const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict');
return `${count} ${text}`;
};
export const isReadyToCommit = (state) => {
const { files } = state.conflictsData;
const hasCommitMessage = state.conflictsData.commitMessage.trim().length;
let unresolved = 0;
for (let i = 0, l = files.length; i < l; i += 1) {
const file = files[i];
if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
let numberConflicts = 0;
const resolvedConflicts = Object.keys(file.resolutionData).length;
// We only check for conflicts type 'text'
// since conflicts `text_editor` can´t be resolved in interactive mode
if (file.type === CONFLICT_TYPES.TEXT) {
for (let j = 0, k = file.sections.length; j < k; j += 1) {
if (file.sections[j].conflict) {
numberConflicts += 1;
}
}
if (resolvedConflicts !== numberConflicts) {
unresolved += 1;
}
}
} else if (file.resolveMode === EDIT_RESOLVE_MODE) {
// Unlikely to happen since switching to Edit mode saves content automatically.
// Checking anyway in case the save strategy changes in the future
if (!file.content) {
unresolved += 1;
// eslint-disable-next-line no-continue
continue;
}
}
}
return !state.isSubmitting && hasCommitMessage && !unresolved;
};
export const getCommitButtonText = (state) => {
const initial = s__('MergeConflict|Commit to source branch');
const inProgress = s__('MergeConflict|Committing...');
return state.isSubmitting ? inProgress : initial;
};
export const getCommitData = (state) => {
let commitData = {};
commitData = {
commit_message: state.conflictsData.commitMessage,
files: [],
};
state.conflictsData.files.forEach((file) => {
const addFile = {
old_path: file.old_path,
new_path: file.new_path,
};
if (file.type === CONFLICT_TYPES.TEXT) {
// Submit only one data for type of editing
if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
addFile.sections = file.resolutionData;
} else if (file.resolveMode === EDIT_RESOLVE_MODE) {
addFile.content = file.content;
}
} else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) {
addFile.content = file.content;
}
commitData.files.push(addFile);
});
return commitData;
};
export const fileTextTypePresent = (state) => {
return state.conflictsData?.files.some((f) => f.type === CONFLICT_TYPES.TEXT);
};
export const getFileIndex = (state) => ({ blobPath }) => {
return state.conflictsData.files.findIndex((f) => f.blobPath === blobPath);
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
state,
getters,
actions,
mutations,
});
export const SET_LOADING_STATE = 'SET_LOADING_STATE';
export const SET_ERROR_STATE = 'SET_ERROR_STATE';
export const SET_FAILED_REQUEST = 'SET_FAILED_REQUEST';
export const SET_VIEW_TYPE = 'SET_VIEW_TYPE';
export const SET_SUBMIT_STATE = 'SET_SUBMIT_STATE';
export const SET_CONFLICTS_DATA = 'SET_CONFLICTS_DATA';
export const UPDATE_FILE = 'UPDATE_FILE';
export const UPDATE_CONFLICTS_DATA = 'UPDATE_CONFLICTS_DATA';
import { VIEW_TYPES } from '../constants';
import * as types from './mutation_types';
export default {
[types.SET_LOADING_STATE]: (state, value) => {
state.isLoading = value;
},
[types.SET_ERROR_STATE]: (state, value) => {
state.hasError = value;
},
[types.SET_FAILED_REQUEST]: (state, value) => {
state.hasError = true;
state.conflictsData.errorMessage = value;
},
[types.SET_VIEW_TYPE]: (state, value) => {
state.diffView = value;
state.isParallel = value === VIEW_TYPES.PARALLEL;
},
[types.SET_SUBMIT_STATE]: (state, value) => {
state.isSubmitting = value;
},
[types.SET_CONFLICTS_DATA]: (state, data) => {
state.conflictsData = {
files: data.files,
commitMessage: data.commit_message,
sourceBranch: data.source_branch,
targetBranch: data.target_branch,
shortCommitSha: data.commit_sha.slice(0, 7),
};
},
[types.UPDATE_CONFLICTS_DATA]: (state, payload) => {
state.conflictsData = {
...state.conflictsData,
...payload,
};
},
[types.UPDATE_FILE]: (state, { file, index }) => {
state.conflictsData.files.splice(index, 1, file);
},
};
import Cookies from 'js-cookie';
import { VIEW_TYPES } from '../constants';
const diffViewType = Cookies.get('diff_view');
export default () => ({
isLoading: true,
hasError: false,
isSubmitting: false,
isParallel: diffViewType === VIEW_TYPES.PARALLEL,
diffViewType,
conflictsData: {},
});
import {
ORIGIN_HEADER_TEXT,
ORIGIN_BUTTON_TITLE,
HEAD_HEADER_TEXT,
HEAD_BUTTON_TITLE,
DEFAULT_RESOLVE_MODE,
CONFLICT_TYPES,
} from './constants';
export const getFilePath = (file) => {
const { old_path, new_path } = file;
// eslint-disable-next-line babel/camelcase
return old_path === new_path ? new_path : `${old_path}${new_path}`;
};
export const checkLineLengths = ({ left, right }) => {
const wLeft = [...left];
const wRight = [...right];
if (left.length !== right.length) {
if (left.length > right.length) {
const diff = left.length - right.length;
for (let i = 0; i < diff; i += 1) {
wRight.push({ lineType: 'emptyLine', richText: '' });
}
} else {
const diff = right.length - left.length;
for (let i = 0; i < diff; i += 1) {
wLeft.push({ lineType: 'emptyLine', richText: '' });
}
}
}
return { left: wLeft, right: wRight };
};
export const getHeadHeaderLine = (id) => {
return {
id,
richText: HEAD_HEADER_TEXT,
buttonTitle: HEAD_BUTTON_TITLE,
type: 'new',
section: 'head',
isHeader: true,
isHead: true,
isSelected: false,
isUnselected: false,
};
};
export const decorateLineForInlineView = (line, id, conflict) => {
const { type } = line;
return {
id,
hasConflict: conflict,
isHead: type === 'new',
isOrigin: type === 'old',
hasMatch: type === 'match',
richText: line.rich_text,
isSelected: false,
isUnselected: false,
};
};
export const getLineForParallelView = (line, id, lineType, isHead) => {
const { old_line, new_line, rich_text } = line;
const hasConflict = lineType === 'conflict';
return {
id,
lineType,
hasConflict,
isHead: hasConflict && isHead,
isOrigin: hasConflict && !isHead,
hasMatch: lineType === 'match',
// eslint-disable-next-line babel/camelcase
lineNumber: isHead ? new_line : old_line,
section: isHead ? 'head' : 'origin',
richText: rich_text,
isSelected: false,
isUnselected: false,
};
};
export const getOriginHeaderLine = (id) => {
return {
id,
richText: ORIGIN_HEADER_TEXT,
buttonTitle: ORIGIN_BUTTON_TITLE,
type: 'old',
section: 'origin',
isHeader: true,
isOrigin: true,
isSelected: false,
isUnselected: false,
};
};
export const setInlineLine = (file) => {
const inlineLines = [];
file.sections.forEach((section) => {
let currentLineType = 'new';
const { conflict, lines, id } = section;
if (conflict) {
inlineLines.push(getHeadHeaderLine(id));
}
lines.forEach((line) => {
const { type } = line;
if ((type === 'new' || type === 'old') && currentLineType !== type) {
currentLineType = type;
inlineLines.push({ lineType: 'emptyLine', richText: '' });
}
const decoratedLine = decorateLineForInlineView(line, id, conflict);
inlineLines.push(decoratedLine);
});
if (conflict) {
inlineLines.push(getOriginHeaderLine(id));
}
});
return inlineLines;
};
export const setParallelLine = (file) => {
const parallelLines = [];
let linesObj = { left: [], right: [] };
file.sections.forEach((section) => {
const { conflict, lines, id } = section;
if (conflict) {
linesObj.left.push(getOriginHeaderLine(id));
linesObj.right.push(getHeadHeaderLine(id));
}
lines.forEach((line) => {
const { type } = line;
if (conflict) {
if (type === 'old') {
linesObj.left.push(getLineForParallelView(line, id, 'conflict'));
} else if (type === 'new') {
linesObj.right.push(getLineForParallelView(line, id, 'conflict', true));
}
} else {
const lineType = type || 'context';
linesObj.left.push(getLineForParallelView(line, id, lineType));
linesObj.right.push(getLineForParallelView(line, id, lineType, true));
}
});
linesObj = checkLineLengths(linesObj);
});
for (let i = 0, len = linesObj.left.length; i < len; i += 1) {
parallelLines.push([linesObj.right[i], linesObj.left[i]]);
}
return parallelLines;
};
export const decorateFiles = (files) => {
return files.map((file) => {
const f = { ...file };
f.content = '';
f.resolutionData = {};
f.promptDiscardConfirmation = false;
f.resolveMode = DEFAULT_RESOLVE_MODE;
f.filePath = getFilePath(file);
f.blobPath = f.blob_path;
if (f.type === CONFLICT_TYPES.TEXT) {
f.showEditor = false;
f.loadEditor = false;
f.inlineLines = setInlineLine(file);
f.parallelLines = setParallelLine(file);
} else if (f.type === CONFLICT_TYPES.TEXT_EDITOR) {
f.showEditor = true;
f.loadEditor = true;
}
return f;
});
};
export const restoreFileLinesState = (file) => {
const inlineLines = file.inlineLines.map((line) => {
if (line.hasConflict || line.isHeader) {
return { ...line, isSelected: false, isUnselected: false };
}
return { ...line };
});
const parallelLines = file.parallelLines.map((lines) => {
const left = { ...lines[0] };
const right = { ...lines[1] };
const isLeftMatch = left.hasConflict || left.isHeader;
const isRightMatch = right.hasConflict || right.isHeader;
if (isLeftMatch || isRightMatch) {
left.isSelected = false;
left.isUnselected = false;
right.isSelected = false;
right.isUnselected = false;
}
return [left, right];
});
return { inlineLines, parallelLines };
};
export const markLine = (line, selection) => {
const updated = { ...line };
if (selection === 'head' && line.isHead) {
updated.isSelected = true;
updated.isUnselected = false;
} else if (selection === 'origin' && updated.isOrigin) {
updated.isSelected = true;
updated.isUnselected = false;
} else {
updated.isSelected = false;
updated.isUnselected = true;
}
return updated;
};
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '~/merge_conflicts/constants';
import * as actions from '~/merge_conflicts/store/actions';
import * as types from '~/merge_conflicts/store/mutation_types';
import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflicts/utils';
jest.mock('~/flash.js');
jest.mock('~/merge_conflicts/utils');
describe('merge conflicts actions', () => {
let mock;
const files = [
{
blobPath: 'a',
},
{ blobPath: 'b' },
];
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('fetchConflictsData', () => {
const conflictsPath = 'conflicts/path/mock';
it('on success dispatches setConflictsData', (done) => {
mock.onGet(conflictsPath).reply(200, {});
testAction(
actions.fetchConflictsData,
conflictsPath,
{},
[
{ type: types.SET_LOADING_STATE, payload: true },
{ type: types.SET_LOADING_STATE, payload: false },
],
[{ type: 'setConflictsData', payload: {} }],
done,
);
});
it('when data has type equal to error ', (done) => {
mock.onGet(conflictsPath).reply(200, { type: 'error', message: 'error message' });
testAction(
actions.fetchConflictsData,
conflictsPath,
{},
[
{ type: types.SET_LOADING_STATE, payload: true },
{ type: types.SET_FAILED_REQUEST, payload: 'error message' },
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
done,
);
});
it('when request fails ', (done) => {
mock.onGet(conflictsPath).reply(400);
testAction(
actions.fetchConflictsData,
conflictsPath,
{},
[
{ type: types.SET_LOADING_STATE, payload: true },
{ type: types.SET_FAILED_REQUEST },
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
done,
);
});
});
describe('submitResolvedConflicts', () => {
useMockLocationHelper();
const resolveConflictsPath = 'resolve/conflicts/path/mock';
it('on success reloads the page', (done) => {
mock.onPost(resolveConflictsPath).reply(200, { redirect_to: 'hrefPath' });
testAction(
actions.submitResolvedConflicts,
resolveConflictsPath,
{},
[{ type: types.SET_SUBMIT_STATE, payload: true }],
[],
() => {
expect(window.location.assign).toHaveBeenCalledWith('hrefPath');
done();
},
);
});
it('on errors shows flash', (done) => {
mock.onPost(resolveConflictsPath).reply(400);
testAction(
actions.submitResolvedConflicts,
resolveConflictsPath,
{},
[
{ type: types.SET_SUBMIT_STATE, payload: true },
{ type: types.SET_SUBMIT_STATE, payload: false },
],
[],
() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Failed to save merge conflicts resolutions. Please try again!',
});
done();
},
);
});
});
describe('setConflictsData', () => {
it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
decorateFiles.mockReturnValue([{ bar: 'baz' }]);
testAction(
actions.setConflictsData,
{ files, foo: 'bar' },
{},
[
{
type: types.SET_CONFLICTS_DATA,
payload: { foo: 'bar', files: [{ bar: 'baz' }] },
},
],
[],
done,
);
});
});
describe('setFileResolveMode', () => {
it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
testAction(
actions.setFileResolveMode,
{ file: files[0], mode: INTERACTIVE_RESOLVE_MODE },
{ conflictsData: { files }, getFileIndex: () => 0 },
[
{
type: types.UPDATE_FILE,
payload: {
file: { ...files[0], showEditor: false, resolveMode: INTERACTIVE_RESOLVE_MODE },
index: 0,
},
},
],
[],
done,
);
});
it('EDIT_RESOLVE_MODE updates the correct file ', (done) => {
restoreFileLinesState.mockReturnValue([]);
const file = {
...files[0],
showEditor: true,
loadEditor: true,
resolutionData: {},
resolveMode: EDIT_RESOLVE_MODE,
};
testAction(
actions.setFileResolveMode,
{ file: files[0], mode: EDIT_RESOLVE_MODE },
{ conflictsData: { files }, getFileIndex: () => 0 },
[
{
type: types.UPDATE_FILE,
payload: {
file,
index: 0,
},
},
],
[],
() => {
expect(restoreFileLinesState).toHaveBeenCalledWith(file);
done();
},
);
});
});
describe('setPromptConfirmationState', () => {
it('updates the correct file ', (done) => {
testAction(
actions.setPromptConfirmationState,
{ file: files[0], promptDiscardConfirmation: true },
{ conflictsData: { files }, getFileIndex: () => 0 },
[
{
type: types.UPDATE_FILE,
payload: {
file: { ...files[0], promptDiscardConfirmation: true },
index: 0,
},
},
],
[],
done,
);
});
});
describe('handleSelected', () => {
const file = {
...files[0],
inlineLines: [{ id: 1, hasConflict: true }, { id: 2 }],
parallelLines: [
[{ id: 1, hasConflict: true }, { id: 1 }],
[{ id: 2 }, { id: 3 }],
],
};
it('updates the correct file ', (done) => {
const marLikeMockReturn = { foo: 'bar' };
markLine.mockReturnValue(marLikeMockReturn);
testAction(
actions.handleSelected,
{ file, line: { id: 1, section: 'baz' } },
{ conflictsData: { files }, getFileIndex: () => 0 },
[
{
type: types.UPDATE_FILE,
payload: {
file: {
...file,
resolutionData: { 1: 'baz' },
inlineLines: [marLikeMockReturn, { id: 2 }],
parallelLines: [
[marLikeMockReturn, marLikeMockReturn],
[{ id: 2 }, { id: 3 }],
],
},
index: 0,
},
},
],
[],
() => {
expect(markLine).toHaveBeenCalledTimes(3);
done();
},
);
});
});
});
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