Commit 4d4c4514 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'design-repo-filtering' into 'master'

Design Repo Sync Status - Filtering/Search

See merge request gitlab-org/gitlab!19589
parents 67d0d86f e97ccf3f
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GeoDesignsFilterBar from './geo_designs_filter_bar.vue';
import GeoDesigns from './geo_designs.vue'; import GeoDesigns from './geo_designs.vue';
import GeoDesignsEmptyState from './geo_designs_empty_state.vue'; import GeoDesignsEmptyState from './geo_designs_empty_state.vue';
import GeoDesignsDisabled from './geo_designs_disabled.vue'; import GeoDesignsDisabled from './geo_designs_disabled.vue';
...@@ -10,6 +11,7 @@ export default { ...@@ -10,6 +11,7 @@ export default {
name: 'GeoDesignsApp', name: 'GeoDesignsApp',
components: { components: {
GlLoadingIcon, GlLoadingIcon,
GeoDesignsFilterBar,
GeoDesigns, GeoDesigns,
GeoDesignsEmptyState, GeoDesignsEmptyState,
GeoDesignsDisabled, GeoDesignsDisabled,
...@@ -56,6 +58,7 @@ export default { ...@@ -56,6 +58,7 @@ export default {
<template> <template>
<article class="geo-designs-container"> <article class="geo-designs-container">
<section v-if="designsEnabled"> <section v-if="designsEnabled">
<geo-designs-filter-bar />
<gl-loading-icon v-if="isLoading" size="xl" /> <gl-loading-icon v-if="isLoading" size="xl" />
<template v-else> <template v-else>
<geo-designs v-if="hasDesigns" /> <geo-designs v-if="hasDesigns" />
......
...@@ -36,11 +36,11 @@ export default { ...@@ -36,11 +36,11 @@ export default {
v-for="design in designs" v-for="design in designs"
:key="design.id" :key="design.id"
:name="design.name" :name="design.name"
:project-id="design.project_id" :project-id="design.projectId"
:sync-status="design.state" :sync-status="design.state"
:last-synced="design.last_synced_at" :last-synced="design.lastSyncedAt"
:last-verified="design.last_verified_at" :last-verified="design.lastVerifiedAt"
:last-checked="design.last_checked_at" :last-checked="design.lastCheckedAt"
/> />
<gl-pagination <gl-pagination
v-if="hasDesigns" v-if="hasDesigns"
......
<script>
import { mapActions, mapState } from 'vuex';
import { debounce } from 'underscore';
import { GlTabs, GlTab, GlFormInput } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { DEFAULT_SEARCH_DELAY } from '../store/constants';
export default {
name: 'GeoDesignsFilterBar',
components: {
GlTabs,
GlTab,
GlFormInput,
Icon,
},
computed: {
...mapState(['currentFilterIndex', 'filterOptions', 'searchFilter']),
search: {
get() {
return this.searchFilter;
},
set: debounce(function debounceSearch(newVal) {
this.setSearch(newVal);
this.fetchDesigns();
}, DEFAULT_SEARCH_DELAY),
},
},
methods: {
...mapActions(['setFilter', 'setSearch', 'fetchDesigns']),
filterChange(filterIndex) {
this.setFilter(filterIndex);
this.fetchDesigns();
},
},
};
</script>
<template>
<gl-tabs :value="currentFilterIndex" @input="filterChange">
<gl-tab
v-for="(filter, index) in filterOptions"
:key="index"
:title="filter"
title-item-class="text-capitalize"
/>
<template v-slot:tabs-end>
<div class="d-flex align-items-center ml-auto">
<gl-form-input v-model="search" type="text" :placeholder="__(`Filter by name...`)" />
</div>
</template>
</gl-tabs>
</template>
import Api from 'ee/api'; import Api from 'ee/api';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import {
parseIntPagination,
normalizeHeaders,
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { FILTER_STATES } from './constants';
// Fetch Designs // Fetch Designs
export const requestDesigns = ({ commit }) => commit(types.REQUEST_DESIGNS); export const requestDesigns = ({ commit }) => commit(types.REQUEST_DESIGNS);
...@@ -16,16 +21,23 @@ export const receiveDesignsError = ({ commit }) => { ...@@ -16,16 +21,23 @@ export const receiveDesignsError = ({ commit }) => {
export const fetchDesigns = ({ state, dispatch }) => { export const fetchDesigns = ({ state, dispatch }) => {
dispatch('requestDesigns'); dispatch('requestDesigns');
const { currentPage: page } = state; const statusFilterName = state.filterOptions[state.currentFilterIndex]
const query = { page }; ? state.filterOptions[state.currentFilterIndex]
: state.filterOptions[0];
const query = {
page: state.currentPage,
search: state.searchFilter ? state.searchFilter : null,
sync_status: statusFilterName === FILTER_STATES.ALL ? null : statusFilterName,
};
Api.getGeoDesigns(query) Api.getGeoDesigns(query)
.then(res => { .then(res => {
const normalizedHeaders = normalizeHeaders(res.headers); const normalizedHeaders = normalizeHeaders(res.headers);
const paginationInformation = parseIntPagination(normalizedHeaders); const paginationInformation = parseIntPagination(normalizedHeaders);
const camelCaseData = convertObjectPropsToCamelCase(res.data, { deep: true });
dispatch('receiveDesignsSuccess', { dispatch('receiveDesignsSuccess', {
data: res.data, data: camelCaseData,
perPage: paginationInformation.perPage, perPage: paginationInformation.perPage,
total: paginationInformation.total, total: paginationInformation.total,
}); });
...@@ -35,7 +47,15 @@ export const fetchDesigns = ({ state, dispatch }) => { ...@@ -35,7 +47,15 @@ export const fetchDesigns = ({ state, dispatch }) => {
}); });
}; };
// Pagination // Filtering/Pagination
export const setFilter = ({ commit }, filterIndex) => {
commit(types.SET_FILTER, filterIndex);
};
export const setSearch = ({ commit }, search) => {
commit(types.SET_SEARCH, search);
};
export const setPage = ({ commit }, page) => { export const setPage = ({ commit }, page) => {
commit(types.SET_PAGE, page); commit(types.SET_PAGE, page);
}; };
...@@ -20,3 +20,5 @@ export const STATUS_ICON_CLASS = { ...@@ -20,3 +20,5 @@ export const STATUS_ICON_CLASS = {
[FILTER_STATES.FAILED]: 'text-danger', [FILTER_STATES.FAILED]: 'text-danger',
[DEFAULT_STATUS]: 'text-muted', [DEFAULT_STATUS]: 'text-muted',
}; };
export const DEFAULT_SEARCH_DELAY = 500;
export const SET_FILTER = 'SET_FILTER';
export const SET_SEARCH = 'SET_SEARCH';
export const SET_PAGE = 'SET_PAGE'; export const SET_PAGE = 'SET_PAGE';
export const REQUEST_DESIGNS = 'REQUEST_DESIGNS'; export const REQUEST_DESIGNS = 'REQUEST_DESIGNS';
......
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.SET_FILTER](state, filterIndex) {
state.currentPage = 1;
state.currentFilterIndex = filterIndex;
},
[types.SET_SEARCH](state, search) {
state.currentPage = 1;
state.searchFilter = search;
},
[types.SET_PAGE](state, page) { [types.SET_PAGE](state, page) {
state.currentPage = page; state.currentPage = page;
}, },
......
import { FILTER_STATES } from './constants';
const createState = () => ({ const createState = () => ({
isLoading: false, isLoading: false,
...@@ -5,5 +7,9 @@ const createState = () => ({ ...@@ -5,5 +7,9 @@ const createState = () => ({
totalDesigns: 0, totalDesigns: 0,
pageSize: 0, pageSize: 0,
currentPage: 1, currentPage: 1,
searchFilter: '',
currentFilterIndex: 0,
filterOptions: Object.values(FILTER_STATES),
}); });
export default createState; export default createState;
...@@ -6,6 +6,7 @@ import store from 'ee/geo_designs/store'; ...@@ -6,6 +6,7 @@ import store from 'ee/geo_designs/store';
import GeoDesignsDisabled from 'ee/geo_designs/components/geo_designs_disabled.vue'; import GeoDesignsDisabled from 'ee/geo_designs/components/geo_designs_disabled.vue';
import GeoDesigns from 'ee/geo_designs/components/geo_designs.vue'; import GeoDesigns from 'ee/geo_designs/components/geo_designs.vue';
import GeoDesignsEmptyState from 'ee/geo_designs/components/geo_designs_empty_state.vue'; import GeoDesignsEmptyState from 'ee/geo_designs/components/geo_designs_empty_state.vue';
import GeoDesignsFilterBar from 'ee/geo_designs/components/geo_designs_filter_bar.vue';
import { import {
MOCK_GEO_SVG_PATH, MOCK_GEO_SVG_PATH,
MOCK_ISSUES_SVG_PATH, MOCK_ISSUES_SVG_PATH,
...@@ -60,6 +61,7 @@ describe('GeoDesignsApp', () => { ...@@ -60,6 +61,7 @@ describe('GeoDesignsApp', () => {
const findGlLoadingIcon = () => findGeoDesignsContainer().find(GlLoadingIcon); const findGlLoadingIcon = () => findGeoDesignsContainer().find(GlLoadingIcon);
const findGeoDesigns = () => findGeoDesignsContainer().find(GeoDesigns); const findGeoDesigns = () => findGeoDesignsContainer().find(GeoDesigns);
const findGeoDesignsEmptyState = () => findGeoDesignsContainer().find(GeoDesignsEmptyState); const findGeoDesignsEmptyState = () => findGeoDesignsContainer().find(GeoDesignsEmptyState);
const findGeoDesignsFilterBar = () => findGeoDesignsContainer().find(GeoDesignsFilterBar);
describe('template', () => { describe('template', () => {
beforeEach(() => { beforeEach(() => {
...@@ -99,6 +101,10 @@ describe('GeoDesignsApp', () => { ...@@ -99,6 +101,10 @@ describe('GeoDesignsApp', () => {
expect(findGeoDesignsEnabledContainer().exists()).toBe(true); expect(findGeoDesignsEnabledContainer().exists()).toBe(true);
}); });
it('renders the filter bar', () => {
expect(findGeoDesignsFilterBar().exists()).toBe(true);
});
describe('when isLoading = true', () => { describe('when isLoading = true', () => {
beforeEach(() => { beforeEach(() => {
wrapper.vm.$store.state.isLoading = true; wrapper.vm.$store.state.isLoading = true;
......
...@@ -14,9 +14,9 @@ describe('GeoDesignsApp', () => { ...@@ -14,9 +14,9 @@ describe('GeoDesignsApp', () => {
const propsData = { const propsData = {
name: mockDesign.name, name: mockDesign.name,
projectId: mockDesign.project_id, projectId: mockDesign.projectId,
syncStatus: mockDesign.state, syncStatus: mockDesign.state,
lastSynced: mockDesign.last_synced_at, lastSynced: mockDesign.lastSyncedAt,
lastVerified: null, lastVerified: null,
lastChecked: null, lastChecked: null,
}; };
......
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import { GlTabs, GlTab, GlFormInput } from '@gitlab/ui';
import GeoDesignsFilterBar from 'ee/geo_designs/components/geo_designs_filter_bar.vue';
import store from 'ee/geo_designs/store';
import { DEFAULT_SEARCH_DELAY } from 'ee/geo_designs/store/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GeoDesignsFilterBar', () => {
let wrapper;
const actionSpies = {
setSearch: jest.fn(),
setFilter: jest.fn(),
fetchDesigns: jest.fn(),
};
const createComponent = () => {
wrapper = mount(localVue.extend(GeoDesignsFilterBar), {
localVue,
store,
methods: {
...actionSpies,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findGlTabsContainer = () => wrapper.find(GlTabs);
const findGlTab = () => findGlTabsContainer().findAll(GlTab);
const findGlFormInput = () => findGlTabsContainer().find(GlFormInput);
describe('template', () => {
beforeEach(() => {
createComponent();
});
describe('GlTab', () => {
it('renders', () => {
expect(findGlTabsContainer().exists()).toBe(true);
});
it('calls setFilter when input event is fired', () => {
findGlTabsContainer().vm.$emit('input');
expect(actionSpies.setFilter).toHaveBeenCalled();
});
});
it('renders an instance of GlTab for each FilterOption', () => {
expect(findGlTab().length).toBe(wrapper.vm.$store.state.filterOptions.length);
});
it('renders GlFormInput', () => {
expect(findGlFormInput().exists()).toBe(true);
});
});
describe('when search changes', () => {
beforeEach(() => {
createComponent();
actionSpies.fetchDesigns.mockClear(); // Will get called on init
wrapper.vm.search = 'test search';
});
it(`should wait ${DEFAULT_SEARCH_DELAY}ms before calling setSearch`, () => {
expect(actionSpies.setSearch).not.toHaveBeenCalledWith('test search');
jest.runAllTimers(); // Debounce
expect(actionSpies.setSearch).toHaveBeenCalledWith('test search');
});
it(`should wait ${DEFAULT_SEARCH_DELAY}ms before calling fetchDesigns`, () => {
expect(actionSpies.fetchDesigns).not.toHaveBeenCalled();
jest.runAllTimers(); // Debounce
expect(actionSpies.fetchDesigns).toHaveBeenCalled();
});
});
describe('filterChange', () => {
const testValue = 2;
beforeEach(() => {
createComponent();
wrapper.vm.filterChange(testValue);
});
it('should call setFilter with the filterIndex', () => {
expect(actionSpies.setFilter).toHaveBeenCalledWith(testValue);
});
it('should call fetchDesigns', () => {
expect(actionSpies.fetchDesigns).toHaveBeenCalled();
});
});
});
...@@ -78,7 +78,7 @@ describe('GeoDesigns', () => { ...@@ -78,7 +78,7 @@ describe('GeoDesigns', () => {
const designs = [...wrapper.vm.$store.state.designs]; const designs = [...wrapper.vm.$store.state.designs];
for (let i = 0; i < designWrappers.length; i += 1) { for (let i = 0; i < designWrappers.length; i += 1) {
expect(designWrappers.at(i).props().projectId).toBe(designs[i].project_id); expect(designWrappers.at(i).props().projectId).toBe(designs[i].projectId);
} }
}); });
}); });
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export const MOCK_GEO_SVG_PATH = 'illustrations/gitlab_geo.svg'; export const MOCK_GEO_SVG_PATH = 'illustrations/gitlab_geo.svg';
export const MOCK_ISSUES_SVG_PATH = 'illustrations/issues.svg'; export const MOCK_ISSUES_SVG_PATH = 'illustrations/issues.svg';
...@@ -32,7 +34,7 @@ export const MOCK_BASIC_FETCH_RESPONSE = { ...@@ -32,7 +34,7 @@ export const MOCK_BASIC_FETCH_RESPONSE = {
}; };
export const MOCK_BASIC_FETCH_DATA_MAP = { export const MOCK_BASIC_FETCH_DATA_MAP = {
data: MOCK_BASIC_FETCH_RESPONSE.data, data: convertObjectPropsToCamelCase(MOCK_BASIC_FETCH_RESPONSE.data, { deep: true }),
perPage: MOCK_BASIC_FETCH_RESPONSE.headers['x-per-page'], perPage: MOCK_BASIC_FETCH_RESPONSE.headers['x-per-page'],
total: MOCK_BASIC_FETCH_RESPONSE.headers['x-total'], total: MOCK_BASIC_FETCH_RESPONSE.headers['x-total'],
}; };
...@@ -11,8 +11,15 @@ jest.mock('~/flash'); ...@@ -11,8 +11,15 @@ jest.mock('~/flash');
describe('GeoDesigns Store Actions', () => { describe('GeoDesigns Store Actions', () => {
let state; let state;
let mock;
beforeEach(() => { beforeEach(() => {
state = createState(); state = createState();
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
}); });
describe('requestDesigns', () => { describe('requestDesigns', () => {
...@@ -49,15 +56,6 @@ describe('GeoDesigns Store Actions', () => { ...@@ -49,15 +56,6 @@ describe('GeoDesigns Store Actions', () => {
}); });
describe('fetchDesigns', () => { describe('fetchDesigns', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('on success', () => { describe('on success', () => {
beforeEach(() => { beforeEach(() => {
mock mock
...@@ -98,9 +96,116 @@ describe('GeoDesigns Store Actions', () => { ...@@ -98,9 +96,116 @@ describe('GeoDesigns Store Actions', () => {
}); });
}); });
describe('queryParams', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock
.onGet()
.replyOnce(200, MOCK_BASIC_FETCH_RESPONSE.data, MOCK_BASIC_FETCH_RESPONSE.headers);
});
describe('no params set', () => {
it('should call fetchDesigns with default queryParams', () => {
state.isLoading = true;
function fetchDesignsCall() {
const callHistory = mock.history.get[0];
expect(callHistory.params.page).toEqual(1);
expect(callHistory.params.search).toBeNull();
expect(callHistory.params.sync_status).toBeNull();
}
testAction(
actions.fetchDesigns,
{},
state,
[],
[
{ type: 'requestDesigns' },
{ type: 'receiveDesignsSuccess', payload: MOCK_BASIC_FETCH_DATA_MAP },
],
fetchDesignsCall,
);
});
});
describe('with params set', () => {
it('should call fetchDesigns with queryParams', () => {
state.isLoading = true;
state.currentPage = 3;
state.searchFilter = 'test search';
state.currentFilterIndex = 2;
function fetchDesignsCall() {
const callHistory = mock.history.get[0];
expect(callHistory.params.page).toEqual(state.currentPage);
expect(callHistory.params.search).toEqual(state.searchFilter);
expect(callHistory.params.sync_status).toEqual(
state.filterOptions[state.currentFilterIndex],
);
}
testAction(
actions.fetchDesigns,
{},
state,
[],
[
{ type: 'requestDesigns' },
{ type: 'receiveDesignsSuccess', payload: MOCK_BASIC_FETCH_DATA_MAP },
],
fetchDesignsCall,
);
});
});
});
describe('setFilter', () => {
it('should commit mutation SET_FILTER', done => {
const testValue = 1;
testAction(
actions.setFilter,
testValue,
state,
[{ type: types.SET_FILTER, payload: testValue }],
[],
done,
);
});
});
describe('setSearch', () => {
it('should commit mutation SET_SEARCH', done => {
const testValue = 'Test Search';
testAction(
actions.setSearch,
testValue,
state,
[{ type: types.SET_SEARCH, payload: testValue }],
[],
done,
);
});
});
describe('setPage', () => { describe('setPage', () => {
it('should commit mutation SET_PAGE', done => { it('should commit mutation SET_PAGE', done => {
testAction(actions.setPage, 2, state, [{ type: types.SET_PAGE, payload: 2 }], [], done); state.currentPage = 1;
const testValue = 2;
testAction(
actions.setPage,
testValue,
state,
[{ type: types.SET_PAGE, payload: testValue }],
[],
done,
);
}); });
}); });
}); });
...@@ -9,10 +9,49 @@ describe('GeoDesigns Store Mutations', () => { ...@@ -9,10 +9,49 @@ describe('GeoDesigns Store Mutations', () => {
state = createState(); state = createState();
}); });
describe('SET_FILTER', () => {
const testValue = 2;
beforeEach(() => {
state.currentFilterIndex = 1;
state.currentPage = 2;
mutations[types.SET_FILTER](state, testValue);
});
it('sets the currentFilterIndex state key', () => {
expect(state.currentFilterIndex).toEqual(testValue);
});
it('resets the page to 1', () => {
expect(state.currentPage).toEqual(1);
});
});
describe('SET_SEARCH', () => {
const testValue = 'test search';
beforeEach(() => {
state.currentPage = 2;
mutations[types.SET_SEARCH](state, testValue);
});
it('sets the searchFilter state key', () => {
expect(state.searchFilter).toEqual(testValue);
});
it('resets the page to 1', () => {
expect(state.currentPage).toEqual(1);
});
});
describe('SET_PAGE', () => { describe('SET_PAGE', () => {
it('sets the page to the correct page', () => { it('sets the currentPage state key', () => {
mutations[types.SET_PAGE](state, 2); const testValue = 2;
expect(state.currentPage).toEqual(2);
mutations[types.SET_PAGE](state, testValue);
expect(state.currentPage).toEqual(testValue);
}); });
}); });
......
...@@ -7795,6 +7795,9 @@ msgstr "" ...@@ -7795,6 +7795,9 @@ msgstr ""
msgid "Filter by milestone name" msgid "Filter by milestone name"
msgstr "" msgstr ""
msgid "Filter by name..."
msgstr ""
msgid "Filter by two-factor authentication" msgid "Filter by two-factor authentication"
msgstr "" msgstr ""
......
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