Commit 790ce5dc authored by Nathan Friend's avatar Nathan Friend Committed by Kushal Pandya

Add Vuex store for Vue ref selector component

Adds a Vuex store in preparation for a new Vue ref selector component.
parent db775720
...@@ -36,7 +36,9 @@ const Api = { ...@@ -36,7 +36,9 @@ const Api = {
userStatusPath: '/api/:version/users/:id/status', userStatusPath: '/api/:version/users/:id/status',
userProjectsPath: '/api/:version/users/:id/projects', userProjectsPath: '/api/:version/users/:id/projects',
userPostStatusPath: '/api/:version/user/status', userPostStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits', commitPath: '/api/:version/projects/:id/repository/commits/:sha',
commitsPath: '/api/:version/projects/:id/repository/commits',
applySuggestionPath: '/api/:version/suggestions/:id/apply', applySuggestionPath: '/api/:version/suggestions/:id/apply',
applySuggestionBatchPath: '/api/:version/suggestions/batch_apply', applySuggestionBatchPath: '/api/:version/suggestions/batch_apply',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines', commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
...@@ -308,9 +310,17 @@ const Api = { ...@@ -308,9 +310,17 @@ const Api = {
.catch(() => flash(__('Something went wrong while fetching projects'))); .catch(() => flash(__('Something went wrong while fetching projects')));
}, },
commit(id, sha, params = {}) {
const url = Api.buildUrl(this.commitPath)
.replace(':id', encodeURIComponent(id))
.replace(':sha', encodeURIComponent(sha));
return axios.get(url, { params });
},
commitMultiple(id, data) { commitMultiple(id, data) {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
const url = Api.buildUrl(Api.commitPath).replace(':id', encodeURIComponent(id)); const url = Api.buildUrl(Api.commitsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, JSON.stringify(data), { return axios.post(url, JSON.stringify(data), {
headers: { headers: {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
......
// This eslint-disable can be removed once a second
// value is added to this file.
/* eslint-disable import/prefer-default-export */
export const X_TOTAL_HEADER = 'x-total';
import Api from '~/api';
import * as types from './mutation_types';
export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
export const setSelectedRef = ({ commit }, selectedRef) =>
commit(types.SET_SELECTED_REF, selectedRef);
export const search = ({ dispatch, commit }, query) => {
commit(types.SET_QUERY, query);
dispatch('searchBranches');
dispatch('searchTags');
dispatch('searchCommits');
};
export const searchBranches = ({ commit, state }) => {
commit(types.REQUEST_START);
Api.branches(state.projectId, state.query)
.then(response => {
commit(types.RECEIVE_BRANCHES_SUCCESS, response);
})
.catch(error => {
commit(types.RECEIVE_BRANCHES_ERROR, error);
})
.finally(() => {
commit(types.REQUEST_FINISH);
});
};
export const searchTags = ({ commit, state }) => {
commit(types.REQUEST_START);
Api.tags(state.projectId, state.query)
.then(response => {
commit(types.RECEIVE_TAGS_SUCCESS, response);
})
.catch(error => {
commit(types.RECEIVE_TAGS_ERROR, error);
})
.finally(() => {
commit(types.REQUEST_FINISH);
});
};
export const searchCommits = ({ commit, state, getters }) => {
// Only query the Commit API if the search query looks like a commit SHA
if (getters.isQueryPossiblyASha) {
commit(types.REQUEST_START);
Api.commit(state.projectId, state.query)
.then(response => {
commit(types.RECEIVE_COMMITS_SUCCESS, response);
})
.catch(error => {
commit(types.RECEIVE_COMMITS_ERROR, error);
})
.finally(() => {
commit(types.REQUEST_FINISH);
});
} else {
commit(types.RESET_COMMIT_MATCHES);
}
};
/** Returns `true` if the query string looks like it could be a commit SHA */
export const isQueryPossiblyASha = ({ query }) => /^[0-9a-f]{4,40}$/i.test(query);
/** Returns `true` if there is at least one in-progress request */
export const isLoading = ({ requestCount }) => requestCount > 0;
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import createState from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
getters,
mutations,
state: createState(),
});
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_SELECTED_REF = 'SET_SELECTED_REF';
export const SET_QUERY = 'SET_QUERY';
export const REQUEST_START = 'REQUEST_START';
export const REQUEST_FINISH = 'REQUEST_FINISH';
export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR';
export const RECEIVE_TAGS_SUCCESS = 'RECEIVE_TAGS_SUCCESS';
export const RECEIVE_TAGS_ERROR = 'RECEIVE_TAGS_ERROR';
export const RECEIVE_COMMITS_SUCCESS = 'RECEIVE_COMMITS_SUCCESS';
export const RECEIVE_COMMITS_ERROR = 'RECEIVE_COMMITS_ERROR';
export const RESET_COMMIT_MATCHES = 'RESET_COMMIT_MATCHES';
import * as types from './mutation_types';
import { X_TOTAL_HEADER } from '../constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
export default {
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
[types.SET_SELECTED_REF](state, selectedRef) {
state.selectedRef = selectedRef;
},
[types.SET_QUERY](state, query) {
state.query = query;
},
[types.REQUEST_START](state) {
state.requestCount += 1;
},
[types.REQUEST_FINISH](state) {
state.requestCount -= 1;
},
[types.RECEIVE_BRANCHES_SUCCESS](state, response) {
state.matches.branches = {
list: convertObjectPropsToCamelCase(response.data).map(b => ({
name: b.name,
default: b.default,
})),
totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
error: null,
};
},
[types.RECEIVE_BRANCHES_ERROR](state, error) {
state.matches.branches = {
list: [],
totalCount: 0,
error,
};
},
[types.RECEIVE_TAGS_SUCCESS](state, response) {
state.matches.tags = {
list: convertObjectPropsToCamelCase(response.data).map(b => ({
name: b.name,
})),
totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
error: null,
};
},
[types.RECEIVE_TAGS_ERROR](state, error) {
state.matches.tags = {
list: [],
totalCount: 0,
error,
};
},
[types.RECEIVE_COMMITS_SUCCESS](state, response) {
const commit = convertObjectPropsToCamelCase(response.data);
state.matches.commits = {
list: [
{
name: commit.shortId,
value: commit.id,
subtitle: commit.title,
},
],
totalCount: 1,
error: null,
};
},
[types.RECEIVE_COMMITS_ERROR](state, error) {
state.matches.commits = {
list: [],
totalCount: 0,
// 404's are expected when the search query doesn't match any commits
// and shouldn't be treated as an actual error
error: error.response?.status !== httpStatusCodes.NOT_FOUND ? error : null,
};
},
[types.RESET_COMMIT_MATCHES](state) {
state.matches.commits = {
list: [],
totalCount: 0,
error: null,
};
},
};
export default () => ({
projectId: null,
query: '',
matches: {
branches: {
list: [],
totalCount: 0,
error: null,
},
tags: {
list: [],
totalCount: 0,
error: null,
},
commits: {
list: [],
totalCount: 0,
error: null,
},
},
selectedRef: null,
requestCount: 0,
});
...@@ -366,6 +366,30 @@ describe('Api', () => { ...@@ -366,6 +366,30 @@ describe('Api', () => {
}); });
}); });
describe('commit', () => {
const projectId = 'user/project';
const sha = 'abcd0123';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent(
projectId,
)}/repository/commits/${sha}`;
it('fetches a single commit', () => {
mock.onGet(expectedUrl).reply(200, { id: sha });
return Api.commit(projectId, sha).then(({ data: commit }) => {
expect(commit.id).toBe(sha);
});
});
it('fetches a single commit without stats', () => {
mock.onGet(expectedUrl, { params: { stats: false } }).reply(200, { id: sha });
return Api.commit(projectId, sha, { stats: false }).then(({ data: commit }) => {
expect(commit.id).toBe(sha);
});
});
});
describe('issueTemplate', () => { describe('issueTemplate', () => {
it('fetches an issue template', done => { it('fetches an issue template', done => {
const namespace = 'some namespace'; const namespace = 'some namespace';
......
import testAction from 'helpers/vuex_action_helper';
import createState from '~/ref/stores/state';
import * as actions from '~/ref/stores/actions';
import * as types from '~/ref/stores/mutation_types';
let mockBranchesReturnValue;
let mockTagsReturnValue;
let mockCommitReturnValue;
jest.mock('~/api', () => ({
// `__esModule: true` is required when mocking modules with default exports:
// https://jestjs.io/docs/en/jest-object#jestmockmodulename-factory-options
__esModule: true,
default: {
branches: () => mockBranchesReturnValue,
tags: () => mockTagsReturnValue,
commit: () => mockCommitReturnValue,
},
}));
describe('Ref selector Vuex store actions', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('setProjectId', () => {
it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => {
const projectId = '4';
testAction(actions.setProjectId, projectId, state, [
{ type: types.SET_PROJECT_ID, payload: projectId },
]);
});
});
describe('setSelectedRef', () => {
it(`commits ${types.SET_SELECTED_REF} with the new selected ref name`, () => {
const selectedRef = 'v1.2.3';
testAction(actions.setSelectedRef, selectedRef, state, [
{ type: types.SET_SELECTED_REF, payload: selectedRef },
]);
});
});
describe('search', () => {
it(`commits ${types.SET_QUERY} with the new search query`, () => {
const query = 'hello';
testAction(
actions.search,
query,
state,
[{ type: types.SET_QUERY, payload: query }],
[{ type: 'searchBranches' }, { type: 'searchTags' }, { type: 'searchCommits' }],
);
});
});
describe('searchBranches', () => {
describe('when the search is successful', () => {
const branchesApiResponse = { data: [{ name: 'my-feature-branch' }] };
beforeEach(() => {
mockBranchesReturnValue = Promise.resolve(branchesApiResponse);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_BRANCHES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchBranches, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_BRANCHES_SUCCESS, payload: branchesApiResponse },
{ type: types.REQUEST_FINISH },
]);
});
});
describe('when the search fails', () => {
const error = new Error('Something went wrong!');
beforeEach(() => {
mockBranchesReturnValue = Promise.reject(error);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_BRANCHES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchBranches, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_BRANCHES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
]);
});
});
});
describe('searchTags', () => {
describe('when the search is successful', () => {
const tagsApiResponse = { data: [{ name: 'v1.2.3' }] };
beforeEach(() => {
mockTagsReturnValue = Promise.resolve(tagsApiResponse);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_TAGS_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchTags, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_TAGS_SUCCESS, payload: tagsApiResponse },
{ type: types.REQUEST_FINISH },
]);
});
});
describe('when the search fails', () => {
const error = new Error('Something went wrong!');
beforeEach(() => {
mockTagsReturnValue = Promise.reject(error);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_TAGS_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchTags, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_TAGS_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
]);
});
});
});
describe('searchCommits', () => {
describe('when the search query potentially matches a commit SHA', () => {
beforeEach(() => {
state.isQueryPossiblyASha = true;
});
describe('when the search is successful', () => {
const commitApiResponse = { data: [{ id: 'abcd1234' }] };
beforeEach(() => {
mockCommitReturnValue = Promise.resolve(commitApiResponse);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_COMMITS_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchCommits, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_COMMITS_SUCCESS, payload: commitApiResponse },
{ type: types.REQUEST_FINISH },
]);
});
});
describe('when the search fails', () => {
const error = new Error('Something went wrong!');
beforeEach(() => {
mockCommitReturnValue = Promise.reject(error);
});
describe('when the search query might match a commit SHA', () => {
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_COMMITS_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchCommits, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_COMMITS_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
]);
});
});
});
});
describe('when the search query will not match a commit SHA', () => {
beforeEach(() => {
state.isQueryPossiblyASha = false;
});
it(`commits ${types.RESET_COMMIT_MATCHES}`, () => {
return testAction(actions.searchCommits, undefined, state, [
{ type: types.RESET_COMMIT_MATCHES },
]);
});
});
});
});
import * as getters from '~/ref/stores/getters';
describe('Ref selector Vuex store getters', () => {
describe('isQueryPossiblyASha', () => {
it.each`
query | isPossiblyASha
${'abcd'} | ${true}
${'ABCD'} | ${true}
${'0123456789abcdef0123456789abcdef01234567'} | ${true}
${'0123456789abcdef0123456789abcdef012345678'} | ${false}
${'abc'} | ${false}
${'ghij'} | ${false}
${' abcd'} | ${false}
${''} | ${false}
${null} | ${false}
${undefined} | ${false}
`(
'returns true when the query potentially refers to a commit SHA',
({ query, isPossiblyASha }) => {
expect(getters.isQueryPossiblyASha({ query })).toBe(isPossiblyASha);
},
);
});
describe('isLoading', () => {
it.each`
requestCount | isLoading
${2} | ${true}
${1} | ${true}
${0} | ${false}
${-1} | ${false}
`('returns true when at least one request is in progress', ({ requestCount, isLoading }) => {
expect(getters.isLoading({ requestCount })).toBe(isLoading);
});
});
});
import createState from '~/ref/stores/state';
import mutations from '~/ref/stores/mutations';
import * as types from '~/ref/stores/mutation_types';
import { X_TOTAL_HEADER } from '~/ref/constants';
describe('Ref selector Vuex store mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('initial state', () => {
it('is created with the correct structure and initial values', () => {
expect(state).toEqual({
projectId: null,
query: '',
matches: {
branches: {
list: [],
totalCount: 0,
error: null,
},
tags: {
list: [],
totalCount: 0,
error: null,
},
commits: {
list: [],
totalCount: 0,
error: null,
},
},
selectedRef: null,
requestCount: 0,
});
});
});
describe(`${types.SET_PROJECT_ID}`, () => {
it('updates the project ID', () => {
const newProjectId = '4';
mutations[types.SET_PROJECT_ID](state, newProjectId);
expect(state.projectId).toBe(newProjectId);
});
});
describe(`${types.SET_SELECTED_REF}`, () => {
it('updates the selected ref', () => {
const newSelectedRef = 'my-feature-branch';
mutations[types.SET_SELECTED_REF](state, newSelectedRef);
expect(state.selectedRef).toBe(newSelectedRef);
});
});
describe(`${types.SET_QUERY}`, () => {
it('updates the search query', () => {
const newQuery = 'hello';
mutations[types.SET_QUERY](state, newQuery);
expect(state.query).toBe(newQuery);
});
});
describe(`${types.REQUEST_START}`, () => {
it('increments requestCount by 1', () => {
mutations[types.REQUEST_START](state);
expect(state.requestCount).toBe(1);
mutations[types.REQUEST_START](state);
expect(state.requestCount).toBe(2);
mutations[types.REQUEST_START](state);
expect(state.requestCount).toBe(3);
});
});
describe(`${types.REQUEST_FINISH}`, () => {
it('decrements requestCount by 1', () => {
state.requestCount = 3;
mutations[types.REQUEST_FINISH](state);
expect(state.requestCount).toBe(2);
mutations[types.REQUEST_FINISH](state);
expect(state.requestCount).toBe(1);
mutations[types.REQUEST_FINISH](state);
expect(state.requestCount).toBe(0);
});
});
describe(`${types.RECEIVE_BRANCHES_SUCCESS}`, () => {
it('updates state.matches.branches based on the provided API response', () => {
const response = {
data: [
{
name: 'master',
default: true,
// everything except "name" and "default" should be stripped
merged: false,
protected: true,
},
{
name: 'my-feature-branch',
default: false,
},
],
headers: {
[X_TOTAL_HEADER]: 37,
},
};
mutations[types.RECEIVE_BRANCHES_SUCCESS](state, response);
expect(state.matches.branches).toEqual({
list: [
{
name: 'master',
default: true,
},
{
name: 'my-feature-branch',
default: false,
},
],
totalCount: 37,
error: null,
});
});
});
describe(`${types.RECEIVE_BRANCHES_ERROR}`, () => {
it('updates state.matches.branches to an empty state with the error object', () => {
const error = new Error('Something went wrong!');
state.matches.branches = {
list: [{ name: 'my-feature-branch' }],
totalCount: 1,
error: null,
};
mutations[types.RECEIVE_BRANCHES_ERROR](state, error);
expect(state.matches.branches).toEqual({
list: [],
totalCount: 0,
error,
});
});
});
describe(`${types.RECEIVE_REQUEST_TAGS_SUCCESS}`, () => {
it('updates state.matches.tags based on the provided API response', () => {
const response = {
data: [
{
name: 'v1.2',
// everything except "name" should be stripped
target: '2695effb5807a22ff3d138d593fd856244e155e7',
},
],
headers: {
[X_TOTAL_HEADER]: 23,
},
};
mutations[types.RECEIVE_TAGS_SUCCESS](state, response);
expect(state.matches.tags).toEqual({
list: [
{
name: 'v1.2',
},
],
totalCount: 23,
error: null,
});
});
});
describe(`${types.RECEIVE_TAGS_ERROR}`, () => {
it('updates state.matches.tags to an empty state with the error object', () => {
const error = new Error('Something went wrong!');
state.matches.tags = {
list: [{ name: 'v1.2' }],
totalCount: 1,
error: null,
};
mutations[types.RECEIVE_TAGS_ERROR](state, error);
expect(state.matches.tags).toEqual({
list: [],
totalCount: 0,
error,
});
});
});
describe(`${types.RECEIVE_COMMITS_SUCCESS}`, () => {
it('updates state.matches.commits based on the provided API response', () => {
const response = {
data: {
id: '2695effb5807a22ff3d138d593fd856244e155e7',
short_id: '2695effb580',
title: 'Initial commit',
// everything except "id", "short_id", and "title" should be stripped
author_name: 'Example User',
},
};
mutations[types.RECEIVE_COMMITS_SUCCESS](state, response);
expect(state.matches.commits).toEqual({
list: [
{
name: '2695effb580',
value: '2695effb5807a22ff3d138d593fd856244e155e7',
subtitle: 'Initial commit',
},
],
totalCount: 1,
error: null,
});
});
});
describe(`${types.RECEIVE_COMMITS_ERROR}`, () => {
it('updates state.matches.commits to an empty state with the error object', () => {
const error = new Error('Something went wrong!');
state.matches.commits = {
list: [{ name: 'abcd0123' }],
totalCount: 1,
error: null,
};
mutations[types.RECEIVE_COMMITS_ERROR](state, error);
expect(state.matches.commits).toEqual({
list: [],
totalCount: 0,
error,
});
});
});
describe(`${types.RESET_COMMIT_MATCHES}`, () => {
it('resets the commit results back to their original (empty) state', () => {
state.matches.commits = {
list: [{ name: 'abcd0123' }],
totalCount: 1,
error: null,
};
mutations[types.RESET_COMMIT_MATCHES](state);
expect(state.matches.commits).toEqual({
list: [],
totalCount: 0,
error: null,
});
});
});
});
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