diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6fa559c6..ff6f4e831 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -441,7 +441,7 @@ jobs: report_name: Coverage Admin Interface type: lcov result_path: ./coverage/lcov.info - min_coverage: 65 + min_coverage: 47 token: ${{ github.token }} ############################################################################## diff --git a/admin/jest.config.js b/admin/jest.config.js index ac132eed2..b7226bd8f 100644 --- a/admin/jest.config.js +++ b/admin/jest.config.js @@ -1,6 +1,11 @@ module.exports = { verbose: true, - collectCoverageFrom: ['src/**/*.{js,vue}', '!**/node_modules/**', '!**/?(*.)+(spec|test).js?(x)'], + collectCoverageFrom: [ + 'src/**/*.{js,vue}', + '!**/node_modules/**', + '!src/assets/**', + '!**/?(*.)+(spec|test).js?(x)', + ], moduleFileExtensions: [ 'js', // 'jsx', diff --git a/admin/package.json b/admin/package.json index 4aebfd131..e3c94f5d8 100644 --- a/admin/package.json +++ b/admin/package.json @@ -32,15 +32,19 @@ "core-js": "^3.6.5", "dotenv-webpack": "^7.0.3", "graphql": "^15.6.1", + "identity-obj-proxy": "^3.0.0", "jest": "26.6.3", + "moment": "^2.29.1", "regenerator-runtime": "^0.13.9", "stats-webpack-plugin": "^0.7.0", "vue": "^2.6.11", "vue-apollo": "^3.0.8", "vue-i18n": "^8.26.5", "vue-jest": "^3.0.7", + "vue-moment": "^4.1.0", "vue-router": "^3.5.3", - "vuex": "^3.6.2" + "vuex": "^3.6.2", + "vuex-persistedstate": "^4.1.0" }, "devDependencies": { "@babel/eslint-parser": "^7.15.8", diff --git a/admin/src/App.spec.js b/admin/src/App.spec.js index b47141972..e77bc578b 100644 --- a/admin/src/App.spec.js +++ b/admin/src/App.spec.js @@ -1,43 +1,20 @@ -import { mount } from '@vue/test-utils' +import { shallowMount } from '@vue/test-utils' import App from './App' const localVue = global.localVue -const storeCommitMock = jest.fn() - -const mocks = { - $store: { - commit: storeCommitMock, - }, +const stubs = { + RouterView: true, } -const localStorageMock = (() => { - let store = {} - - return { - getItem: (key) => { - return store[key] || null - }, - setItem: (key, value) => { - store[key] = value.toString() - }, - removeItem: (key) => { - delete store[key] - }, - clear: () => { - store = {} - }, - } -})() - describe('App', () => { let wrapper const Wrapper = () => { - return mount(App, { localVue, mocks }) + return shallowMount(App, { localVue, stubs }) } - describe('mount', () => { + describe('shallowMount', () => { beforeEach(() => { wrapper = Wrapper() }) @@ -46,23 +23,4 @@ describe('App', () => { expect(wrapper.find('div#app').exists()).toBeTruthy() }) }) - - describe('window localStorage is undefined', () => { - it('does not commit a token to the store', () => { - expect(storeCommitMock).not.toBeCalled() - }) - }) - - describe('with token in local storage', () => { - beforeEach(() => { - Object.defineProperty(window, 'localStorage', { - value: localStorageMock, - }) - window.localStorage.setItem('vuex', JSON.stringify({ token: 1234 })) - }) - - it.skip('commits the token to the store', () => { - expect(storeCommitMock).toBeCalledWith('token', 1234) - }) - }) }) diff --git a/admin/src/App.vue b/admin/src/App.vue index 9267cc82b..a76b1dcab 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -1,9 +1,19 @@ diff --git a/admin/src/assets/mocks/styleMock.js b/admin/src/assets/mocks/styleMock.js new file mode 100644 index 000000000..4ba52ba2c --- /dev/null +++ b/admin/src/assets/mocks/styleMock.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/admin/src/components/ContentFooter.vue b/admin/src/components/ContentFooter.vue new file mode 100644 index 000000000..ade4e0a83 --- /dev/null +++ b/admin/src/components/ContentFooter.vue @@ -0,0 +1,15 @@ + + diff --git a/admin/src/components/CreationFormular.spec.js b/admin/src/components/CreationFormular.spec.js new file mode 100644 index 000000000..fcdf97cfa --- /dev/null +++ b/admin/src/components/CreationFormular.spec.js @@ -0,0 +1,141 @@ +import { mount } from '@vue/test-utils' +import CreationFormular from './CreationFormular.vue' + +const localVue = global.localVue + +const mocks = { + $moment: jest.fn(() => { + return { + format: jest.fn((m) => m), + subtract: jest.fn(() => { + return { + format: jest.fn((m) => m), + } + }), + } + }), +} + +const propsData = { + type: '', + item: {}, + creation: [], + itemsMassCreation: {}, +} + +describe('CreationFormular', () => { + let wrapper + + const Wrapper = () => { + return mount(CreationFormular, { localVue, mocks, propsData }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('has a DIV element with the class.component-creation-formular', () => { + expect(wrapper.find('.component-creation-formular').exists()).toBeTruthy() + }) + + describe('radio buttons to selcet month', () => { + it('has three radio buttons', () => { + expect(wrapper.findAll('input[type="radio"]').length).toBe(3) + }) + + describe('with mass creation', () => { + beforeEach(async () => { + jest.clearAllMocks() + await wrapper.setProps({ type: 'massCreation' }) + }) + + describe('first radio button', () => { + beforeEach(async () => { + await wrapper.findAll('input[type="radio"]').at(0).setChecked() + }) + + it('emits update-radio-selected with index 0', () => { + expect(wrapper.emitted()['update-radio-selected']).toEqual([ + [expect.arrayContaining([0])], + ]) + }) + }) + + describe('second radio button', () => { + beforeEach(async () => { + await wrapper.findAll('input[type="radio"]').at(1).setChecked() + }) + + it('emits update-radio-selected with index 1', () => { + expect(wrapper.emitted()['update-radio-selected']).toEqual([ + [expect.arrayContaining([1])], + ]) + }) + }) + + describe('third radio button', () => { + beforeEach(async () => { + await wrapper.findAll('input[type="radio"]').at(2).setChecked() + }) + + it('emits update-radio-selected with index 2', () => { + expect(wrapper.emitted()['update-radio-selected']).toEqual([ + [expect.arrayContaining([2])], + ]) + }) + }) + }) + + describe('with single creation', () => { + beforeEach(async () => { + jest.clearAllMocks() + await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] }) + await wrapper.setData({ rangeMin: 180 }) + }) + + describe('first radio button', () => { + beforeEach(async () => { + await wrapper.findAll('input[type="radio"]').at(0).setChecked() + }) + + it('sets rangeMin to 0', () => { + expect(wrapper.vm.rangeMin).toBe(0) + }) + + it('sets rangeMax to 200', () => { + expect(wrapper.vm.rangeMax).toBe(200) + }) + }) + + describe('second radio button', () => { + beforeEach(async () => { + await wrapper.findAll('input[type="radio"]').at(1).setChecked() + }) + + it('sets rangeMin to 0', () => { + expect(wrapper.vm.rangeMin).toBe(0) + }) + + it('sets rangeMax to 400', () => { + expect(wrapper.vm.rangeMax).toBe(400) + }) + }) + + describe('third radio button', () => { + beforeEach(async () => { + await wrapper.findAll('input[type="radio"]').at(2).setChecked() + }) + + it('sets rangeMin to 0', () => { + expect(wrapper.vm.rangeMin).toBe(0) + }) + + it('sets rangeMax to 400', () => { + expect(wrapper.vm.rangeMax).toBe(600) + }) + }) + }) + }) + }) +}) diff --git a/admin/src/components/CreationFormular.vue b/admin/src/components/CreationFormular.vue new file mode 100644 index 000000000..d6b637152 --- /dev/null +++ b/admin/src/components/CreationFormular.vue @@ -0,0 +1,290 @@ + + diff --git a/admin/src/components/NavBar.spec.js b/admin/src/components/NavBar.spec.js new file mode 100644 index 000000000..1d68b16ad --- /dev/null +++ b/admin/src/components/NavBar.spec.js @@ -0,0 +1,30 @@ +import { mount } from '@vue/test-utils' +import NavBar from './NavBar.vue' + +const localVue = global.localVue + +const mocks = { + $store: { + state: { + openCreations: 1, + }, + }, +} + +describe('NavBar', () => { + let wrapper + + const Wrapper = () => { + return mount(NavBar, { mocks, localVue }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('has a DIV element with the class.component-nabvar', () => { + expect(wrapper.find('.component-nabvar').exists()).toBeTruthy() + }) + }) +}) diff --git a/admin/src/components/NavBar.vue b/admin/src/components/NavBar.vue new file mode 100644 index 000000000..de9cfe6b2 --- /dev/null +++ b/admin/src/components/NavBar.vue @@ -0,0 +1,30 @@ + + diff --git a/admin/src/components/UserTable.spec.js b/admin/src/components/UserTable.spec.js new file mode 100644 index 000000000..3db0131a3 --- /dev/null +++ b/admin/src/components/UserTable.spec.js @@ -0,0 +1,29 @@ +import { mount } from '@vue/test-utils' +import UserTable from './UserTable.vue' + +const localVue = global.localVue + +describe('UserTable', () => { + let wrapper + + const propsData = { + type: 'Type', + itemsUser: [], + fieldsTable: [], + creation: [], + } + + const Wrapper = () => { + return mount(UserTable, { localVue, propsData }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('has a DIV element with the class.component-user-table', () => { + expect(wrapper.find('.component-user-table').exists()).toBeTruthy() + }) + }) +}) diff --git a/admin/src/components/UserTable.vue b/admin/src/components/UserTable.vue new file mode 100644 index 000000000..265c2d12e --- /dev/null +++ b/admin/src/components/UserTable.vue @@ -0,0 +1,254 @@ + + + + diff --git a/admin/src/graphql/searchUsers.js b/admin/src/graphql/searchUsers.js new file mode 100644 index 000000000..86e333845 --- /dev/null +++ b/admin/src/graphql/searchUsers.js @@ -0,0 +1,12 @@ +import gql from 'graphql-tag' + +export const searchUsers = gql` + query ($searchText: String!) { + searchUsers(searchText: $searchText) { + firstName + lastName + email + creation + } + } +` diff --git a/admin/src/i18n.test.js b/admin/src/i18n.test.js new file mode 100644 index 000000000..e39e0e824 --- /dev/null +++ b/admin/src/i18n.test.js @@ -0,0 +1,30 @@ +import i18n from './i18n' +import VueI18n from 'vue-i18n' + +jest.mock('vue-i18n') + +describe('i18n', () => { + it('calls i18n with locale en', () => { + expect(VueI18n).toBeCalledWith( + expect.objectContaining({ + locale: 'en', + }), + ) + }) + + it('calls i18n with fallback locale en', () => { + expect(VueI18n).toBeCalledWith( + expect.objectContaining({ + fallbackLocale: 'en', + }), + ) + }) + + it('has a _t function', () => { + expect(i18n).toEqual( + expect.objectContaining({ + _t: expect.anything(), + }), + ) + }) +}) diff --git a/admin/src/main.js b/admin/src/main.js index 7375393d9..3be3ae0bf 100644 --- a/admin/src/main.js +++ b/admin/src/main.js @@ -16,12 +16,17 @@ import VueApollo from 'vue-apollo' import CONFIG from './config' -import { BootstrapVue } from 'bootstrap-vue' +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' +import 'bootstrap/dist/css/bootstrap.css' +import 'bootstrap-vue/dist/bootstrap-vue.css' + +import moment from 'vue-moment' const httpLink = new HttpLink({ uri: CONFIG.GRAPHQL_URI }) const authLink = new ApolloLink((operation, forward) => { const token = store.state.token + operation.setContext({ headers: { Authorization: token && token.length > 0 ? `Bearer ${token}` : '', @@ -52,9 +57,16 @@ const apolloProvider = new VueApollo({ Vue.use(BootstrapVue) +Vue.use(IconsPlugin) + +Vue.use(moment) + +Vue.use(VueApollo) + addNavigationGuards(router, store) new Vue({ + moment, router, store, i18n, diff --git a/admin/src/main.test.js b/admin/src/main.test.js index 27c8898ab..c639593d6 100644 --- a/admin/src/main.test.js +++ b/admin/src/main.test.js @@ -5,10 +5,13 @@ import CONFIG from './config' import Vue from 'vue' import Vuex from 'vuex' import VueI18n from 'vue-i18n' +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' +import moment from 'vue-moment' jest.mock('vue') jest.mock('vuex') jest.mock('vue-i18n') +jest.mock('vue-moment') const storeMock = jest.fn() Vuex.Store = storeMock @@ -25,6 +28,16 @@ jest.mock('apollo-boost', () => { } }) +jest.mock('bootstrap-vue', () => { + return { + __esModule: true, + BootstrapVue: jest.fn(), + IconsPlugin: jest.fn(() => { + return { concat: jest.fn() } + }), + } +}) + describe('main', () => { it('calls the HttpLink', () => { expect(HttpLink).toBeCalledWith({ uri: CONFIG.GRAPHQL_URI }) @@ -50,6 +63,18 @@ describe('main', () => { expect(VueI18n).toBeCalled() }) + it.skip('calls BootstrapVue', () => { + expect(BootstrapVue).toBeCalled() + }) + + it.skip('calls IconsPlugin', () => { + expect(IconsPlugin).toBeCalled() + }) + + it.skip('calls Moment', () => { + expect(moment).toBeCalled() + }) + it.skip('creates a store', () => { expect(storeMock).toBeCalled() }) diff --git a/admin/src/router/router.test.js b/admin/src/router/router.test.js new file mode 100644 index 000000000..8e2e70d4d --- /dev/null +++ b/admin/src/router/router.test.js @@ -0,0 +1,92 @@ +import router from './router' + +describe('router', () => { + describe('options', () => { + const { options } = router + const { scrollBehavior, routes } = options + + it('has "/admin" as base', () => { + expect(options).toEqual( + expect.objectContaining({ + base: '/admin', + }), + ) + }) + + it('has "active" as linkActiveClass', () => { + expect(options).toEqual( + expect.objectContaining({ + linkActiveClass: 'active', + }), + ) + }) + + it('has "history" as mode', () => { + expect(options).toEqual( + expect.objectContaining({ + mode: 'history', + }), + ) + }) + + describe('scroll behavior', () => { + it('returns save position when given', () => { + expect(scrollBehavior({}, {}, 'given')).toBe('given') + }) + + it('returns selector when hash is given', () => { + expect(scrollBehavior({ hash: '#to' }, {})).toEqual({ selector: '#to' }) + }) + + it('returns top left coordinates as default', () => { + expect(scrollBehavior({}, {})).toEqual({ x: 0, y: 0 }) + }) + }) + + describe('routes', () => { + it('has "/overview" as default', async () => { + const component = await routes.find((r) => r.path === '/').component() + expect(component.default.name).toBe('overview') + }) + + it('has fourteen routes defined', () => { + expect(routes).toHaveLength(6) + }) + + describe('overview', () => { + it('loads the "Overview" component', async () => { + const component = await routes.find((r) => r.path === '/overview').component() + expect(component.default.name).toBe('overview') + }) + }) + + describe('user', () => { + it('loads the "UserSearch" component', async () => { + const component = await routes.find((r) => r.path === '/user').component() + expect(component.default.name).toBe('UserSearch') + }) + }) + + describe('creation', () => { + it('loads the "Creation" component', async () => { + const component = await routes.find((r) => r.path === '/creation').component() + expect(component.default.name).toBe('Creation') + }) + }) + + describe('creation-confirm', () => { + it('loads the "CreationConfirm" component', async () => { + const component = await routes.find((r) => r.path === '/creation-confirm').component() + expect(component.default.name).toBe('CreationConfirm') + }) + }) + + describe('not found page', () => { + it('renders the "NotFound" component', async () => { + const component = await routes.find((r) => r.path === '*').component() + expect(component.default.name).toEqual('not-found') + }) + }) + }) + }) +}) diff --git a/admin/src/router/routes.js b/admin/src/router/routes.js index 604b03bee..a13463e08 100644 --- a/admin/src/router/routes.js +++ b/admin/src/router/routes.js @@ -1,15 +1,43 @@ -import NotFound from '@/components/NotFoundPage.vue' - const routes = [ { path: '/', - /* + component: () => import('@/views/Overview.vue'), meta: { requiresAuth: true, }, - */ }, - { path: '*', component: NotFound }, + { + path: '/overview', + component: () => import('@/views/Overview.vue'), + meta: { + requiresAuth: true, + }, + }, + { + path: '/user', + component: () => import('@/views/UserSearch.vue'), + meta: { + requiresAuth: true, + }, + }, + { + path: '/creation', + component: () => import('@/views/Creation.vue'), + meta: { + requiresAuth: true, + }, + }, + { + path: '/creation-confirm', + component: () => import('@/views/CreationConfirm.vue'), + meta: { + requiresAuth: true, + }, + }, + { + path: '*', + component: () => import('@/components/NotFoundPage.vue'), + }, ] export default routes diff --git a/admin/src/store/store.js b/admin/src/store/store.js index 709ac52d0..d199368fb 100644 --- a/admin/src/store/store.js +++ b/admin/src/store/store.js @@ -1,19 +1,37 @@ import Vuex from 'vuex' import Vue from 'vue' +import createPersistedState from 'vuex-persistedstate' Vue.use(Vuex) export const mutations = { + openCreationsPlus: (state, i) => { + state.openCreations += i + }, + openCreationsMinus: (state, i) => { + state.openCreations -= i + }, + resetOpenCreations: (state) => { + state.openCreations = 0 + }, token: (state, token) => { state.token = token }, } const store = new Vuex.Store({ - mutations, + plugins: [ + createPersistedState({ + storage: window.localStorage, + }), + ], state: { - token: 'some-token', + token: 'some-valid-token', + moderator: 'Dertest Moderator', + openCreations: 0, }, + // Syncronous mutation of the state + mutations, }) export default store diff --git a/admin/src/store/store.test.js b/admin/src/store/store.test.js index 9ab9d980b..81d75f05f 100644 --- a/admin/src/store/store.test.js +++ b/admin/src/store/store.test.js @@ -1,6 +1,6 @@ import { mutations } from './store' -const { token } = mutations +const { token, openCreationsPlus, openCreationsMinus, resetOpenCreations } = mutations describe('Vuex store', () => { describe('mutations', () => { @@ -11,5 +11,29 @@ describe('Vuex store', () => { expect(state.token).toEqual('1234') }) }) + + describe('openCreationsPlus', () => { + it('increases the open creations by a given number', () => { + const state = { openCreations: 0 } + openCreationsPlus(state, 12) + expect(state.openCreations).toEqual(12) + }) + }) + + describe('openCreationsMinus', () => { + it('decreases the open creations by a given number', () => { + const state = { openCreations: 12 } + openCreationsMinus(state, 2) + expect(state.openCreations).toEqual(10) + }) + }) + + describe('resetOpenCreations', () => { + it('sets the open creations to 0', () => { + const state = { openCreations: 24 } + resetOpenCreations(state) + expect(state.openCreations).toEqual(0) + }) + }) }) }) diff --git a/admin/src/views/Creation.spec.js b/admin/src/views/Creation.spec.js new file mode 100644 index 000000000..02c2ed4ce --- /dev/null +++ b/admin/src/views/Creation.spec.js @@ -0,0 +1,59 @@ +import { mount } from '@vue/test-utils' +import Creation from './Creation.vue' + +const localVue = global.localVue + +const apolloQueryMock = jest.fn().mockResolvedValue({ + data: { + searchUsers: [ + { + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + creation: [200, 400, 600], + }, + ], + }, +}) + +const toastErrorMock = jest.fn() + +const mocks = { + $apollo: { + query: apolloQueryMock, + }, + $toasted: { + error: toastErrorMock, + }, +} + +describe('Creation', () => { + let wrapper + + const Wrapper = () => { + return mount(Creation, { localVue, mocks }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('has a DIV element with the class.creation', () => { + expect(wrapper.find('div.creation').exists()).toBeTruthy() + }) + + describe('apollo returns error', () => { + beforeEach(() => { + apolloQueryMock.mockRejectedValue({ + message: 'Ouch', + }) + wrapper = Wrapper() + }) + + it('toasts an error message', () => { + expect(toastErrorMock).toBeCalledWith('Ouch') + }) + }) + }) +}) diff --git a/admin/src/views/Creation.vue b/admin/src/views/Creation.vue new file mode 100644 index 000000000..7ab900b43 --- /dev/null +++ b/admin/src/views/Creation.vue @@ -0,0 +1,143 @@ + + diff --git a/admin/src/views/CreationConfirm.spec.js b/admin/src/views/CreationConfirm.spec.js new file mode 100644 index 000000000..caf94cd37 --- /dev/null +++ b/admin/src/views/CreationConfirm.spec.js @@ -0,0 +1,53 @@ +import { mount } from '@vue/test-utils' +import CreationConfirm from './CreationConfirm.vue' + +const localVue = global.localVue + +const storeCommitMock = jest.fn() + +const mocks = { + $store: { + commit: storeCommitMock, + }, +} + +describe('CreationConfirm', () => { + let wrapper + + const Wrapper = () => { + return mount(CreationConfirm, { localVue, mocks }) + } + + describe('mount', () => { + beforeEach(() => { + jest.clearAllMocks() + wrapper = Wrapper() + }) + + it('has a DIV element with the class.creation-confirm', () => { + expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy() + }) + + describe('store', () => { + it('commits resetOpenCreations to store', () => { + expect(storeCommitMock).toBeCalledWith('resetOpenCreations') + }) + + it('commits openCreationsPlus to store', () => { + expect(storeCommitMock).toBeCalledWith('openCreationsPlus', 5) + }) + }) + + describe('confirm creation', () => { + beforeEach(async () => { + await wrapper + .findComponent({ name: 'UserTable' }) + .vm.$emit('remove-confirm-result', 1, 'remove') + }) + + it('commits openCreationsMinus to store', () => { + expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1) + }) + }) + }) +}) diff --git a/admin/src/views/CreationConfirm.vue b/admin/src/views/CreationConfirm.vue new file mode 100644 index 000000000..0d68635e0 --- /dev/null +++ b/admin/src/views/CreationConfirm.vue @@ -0,0 +1,149 @@ + + diff --git a/admin/src/views/Overview.vue b/admin/src/views/Overview.vue new file mode 100644 index 000000000..056bb14a6 --- /dev/null +++ b/admin/src/views/Overview.vue @@ -0,0 +1,82 @@ + + diff --git a/admin/src/views/UserSearch.spec.js b/admin/src/views/UserSearch.spec.js new file mode 100644 index 000000000..37ba4f5ec --- /dev/null +++ b/admin/src/views/UserSearch.spec.js @@ -0,0 +1,59 @@ +import { mount } from '@vue/test-utils' +import UserSearch from './UserSearch.vue' + +const localVue = global.localVue + +const apolloQueryMock = jest.fn().mockResolvedValue({ + data: { + searchUsers: [ + { + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + creation: [200, 400, 600], + }, + ], + }, +}) + +const toastErrorMock = jest.fn() + +const mocks = { + $apollo: { + query: apolloQueryMock, + }, + $toasted: { + error: toastErrorMock, + }, +} + +describe('UserSearch', () => { + let wrapper + + const Wrapper = () => { + return mount(UserSearch, { localVue, mocks }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('has a DIV element with the class.user-search', () => { + expect(wrapper.find('div.user-search').exists()).toBeTruthy() + }) + + describe('apollo returns error', () => { + beforeEach(() => { + apolloQueryMock.mockRejectedValue({ + message: 'Ouch', + }) + wrapper = Wrapper() + }) + + it('toasts an error message', () => { + expect(toastErrorMock).toBeCalledWith('Ouch') + }) + }) + }) +}) diff --git a/admin/src/views/UserSearch.vue b/admin/src/views/UserSearch.vue new file mode 100644 index 000000000..ae0ade7b2 --- /dev/null +++ b/admin/src/views/UserSearch.vue @@ -0,0 +1,70 @@ + + diff --git a/admin/test/testSetup.js b/admin/test/testSetup.js index 3b6b50218..caaa3c19c 100644 --- a/admin/test/testSetup.js +++ b/admin/test/testSetup.js @@ -1,6 +1,6 @@ import { createLocalVue } from '@vue/test-utils' import Vue from 'vue' -import { BootstrapVue } from 'bootstrap-vue' +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' // without this async calls are not working import 'regenerator-runtime' @@ -8,6 +8,7 @@ import 'regenerator-runtime' global.localVue = createLocalVue() global.localVue.use(BootstrapVue) +global.localVue.use(IconsPlugin) // throw errors for vue warnings to force the programmers to take care about warnings Vue.config.warnHandler = (w) => { diff --git a/admin/yarn.lock b/admin/yarn.lock index fbc12c105..d7960320b 100644 --- a/admin/yarn.lock +++ b/admin/yarn.lock @@ -6424,6 +6424,11 @@ har-validator@~5.1.3: ajv "^6.12.3" har-schema "^2.0.0" +harmony-reflect@^1.4.6: + version "1.6.2" + resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.2.tgz#31ecbd32e648a34d030d86adb67d4d47547fe710" + integrity sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g== + has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" @@ -6776,6 +6781,13 @@ icss-utils@^4.0.0, icss-utils@^4.1.1: dependencies: postcss "^7.0.14" +identity-obj-proxy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz#94d2bda96084453ef36fbc5aaec37e0f79f1fc14" + integrity sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ= + dependencies: + harmony-reflect "^1.4.6" + ieee754@^1.1.4: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -9020,6 +9032,11 @@ mkdirp@0.x, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: dependencies: minimist "^1.2.5" +moment@^2.19.2, moment@^2.29.1: + version "2.29.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" @@ -11163,6 +11180,11 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== +shvl@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/shvl/-/shvl-2.0.3.tgz#eb4bd37644f5684bba1fc52c3010c96fb5e6afd1" + integrity sha512-V7C6S9Hlol6SzOJPnQ7qzOVEWUQImt3BNmmzh40wObhla3XOYMe4gGiYzLrJd5TFa+cI2f9LKIRJTTKZSTbWgw== + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" @@ -12469,6 +12491,13 @@ vue-loader@^15.9.2: vue-hot-reload-api "^2.3.0" vue-style-loader "^4.1.0" +vue-moment@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/vue-moment/-/vue-moment-4.1.0.tgz#092a8ff723a96c6f85a0a8e23ad30f0bf320f3b0" + integrity sha512-Gzisqpg82ItlrUyiD9d0Kfru+JorW2o4mQOH06lEDZNgxci0tv/fua1Hl0bo4DozDV2JK1r52Atn/8QVCu8qQw== + dependencies: + moment "^2.19.2" + vue-router@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.5.3.tgz#041048053e336829d05dafacf6a8fb669a2e7999" @@ -12500,6 +12529,14 @@ vue@^2.6.11: resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235" integrity sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ== +vuex-persistedstate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/vuex-persistedstate/-/vuex-persistedstate-4.1.0.tgz#127165f85f5b4534fb3170a5d3a8be9811bd2a53" + integrity sha512-3SkEj4NqwM69ikJdFVw6gObeB0NHyspRYMYkR/EbhR0hbvAKyR5gksVhtAfY1UYuWUOCCA0QNGwv9pOwdj+XUQ== + dependencies: + deepmerge "^4.2.2" + shvl "^2.0.3" + vuex@^3.6.2: version "3.6.2" resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71" diff --git a/backend/src/graphql/model/UserAdmin.ts b/backend/src/graphql/model/UserAdmin.ts new file mode 100644 index 000000000..cbf8dd21c --- /dev/null +++ b/backend/src/graphql/model/UserAdmin.ts @@ -0,0 +1,16 @@ +import { ObjectType, Field } from 'type-graphql' + +@ObjectType() +export class UserAdmin { + @Field(() => String) + email: string + + @Field(() => String) + firstName: string + + @Field(() => String) + lastName: string + + @Field(() => [Number]) + creation: number[] +} diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts new file mode 100644 index 000000000..9af50faad --- /dev/null +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -0,0 +1,26 @@ +import { Resolver, Query, Arg } from 'type-graphql' +import { getCustomRepository } from 'typeorm' +import { UserAdmin } from '../model/UserAdmin' +import { LoginUserRepository } from '../../typeorm/repository/LoginUser' + +@Resolver() +export class AdminResolver { + @Query(() => [UserAdmin]) + async searchUsers(@Arg('searchText') searchText: string): Promise { + const loginUserRepository = getCustomRepository(LoginUserRepository) + const loginUsers = await loginUserRepository.findBySearchCriteria(searchText) + const users = loginUsers.map((loginUser) => { + const user = new UserAdmin() + user.firstName = loginUser.firstName + user.lastName = loginUser.lastName + user.email = loginUser.email + user.creation = [ + (Math.floor(Math.random() * 50) + 1) * 20, + (Math.floor(Math.random() * 50) + 1) * 20, + (Math.floor(Math.random() * 50) + 1) * 20, + ] + return user + }) + return users + } +} diff --git a/backend/src/typeorm/repository/LoginUser.ts b/backend/src/typeorm/repository/LoginUser.ts index 65ac6f67b..ac7ff31b6 100644 --- a/backend/src/typeorm/repository/LoginUser.ts +++ b/backend/src/typeorm/repository/LoginUser.ts @@ -8,4 +8,17 @@ export class LoginUserRepository extends Repository { .where('loginUser.email = :email', { email }) .getOneOrFail() } + + async findBySearchCriteria(searchCriteria: string): Promise { + return await this.createQueryBuilder('user') + .where( + 'user.firstName like :name or user.lastName like :lastName or user.email like :email', + { + name: `%${searchCriteria}%`, + lastName: `%${searchCriteria}%`, + email: `%${searchCriteria}%`, + }, + ) + .getMany() + } }