diff --git a/quasar.config.js b/quasar.config.js index b9d75477f..19e81bc13 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -78,6 +78,14 @@ module.exports = configure(function (ctx) { }, port: 8080, open: true, // opens browser window automatically + proxy: { + '/api': { + target: 'http://localhost:3000', + logLevel: 'debug', + changeOrigin: true, + secure: false, + }, + }, }, // https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-framework @@ -95,7 +103,7 @@ module.exports = configure(function (ctx) { // directives: [], // Quasar plugins - plugins: [], + plugins: ['Notify', 'Dialog'], }, // animations: 'all', // --- includes all animations diff --git a/src/assets/logo.svg b/src/assets/logo.svg new file mode 100644 index 000000000..1fae1108f --- /dev/null +++ b/src/assets/logo.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/logo_icon.svg b/src/assets/logo_icon.svg new file mode 100644 index 000000000..b6fdcca4e --- /dev/null +++ b/src/assets/logo_icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/quasar-logo-vertical.svg b/src/assets/quasar-logo-vertical.svg deleted file mode 100644 index 821083104..000000000 --- a/src/assets/quasar-logo-vertical.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/src/boot/axios.js b/src/boot/axios.js index 7c9b94a7b..1962a452c 100644 --- a/src/boot/axios.js +++ b/src/boot/axios.js @@ -1,5 +1,7 @@ import { boot } from 'quasar/wrappers'; import axios from 'axios'; +import { useSession } from 'src/composables/useSession'; + // Be careful when using SSR for cross-request state pollution // due to creating a Singleton instance here; @@ -8,6 +10,22 @@ import axios from 'axios'; // "export default () => {}" function below (which runs individually // for each client) const api = axios.create({ baseURL: 'https://api.example.com' }); +const { getToken } = useSession(); + +axios.interceptors.request.use( + function (context) { + const token = getToken(); + + if (token.length && context.headers) { + context.headers.Authorization = token; + } + + return context; + }, + function (error) { + return Promise.reject(error); + } +); export default boot(({ app }) => { // for use inside Vue files (Options API) through this.$axios and this.$api diff --git a/src/boot/i18n.js b/src/boot/i18n.js index ab6d345b5..aa450c279 100644 --- a/src/boot/i18n.js +++ b/src/boot/i18n.js @@ -4,7 +4,7 @@ import messages from 'src/i18n'; export default boot(({ app }) => { const i18n = createI18n({ - locale: 'en-US', + locale: 'en', messages, }); diff --git a/src/components/EssentialLink.vue b/src/components/EssentialLink.vue deleted file mode 100644 index 49aee346c..000000000 --- a/src/components/EssentialLink.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/src/components/Navbar.vue b/src/components/Navbar.vue new file mode 100644 index 000000000..d5382b264 --- /dev/null +++ b/src/components/Navbar.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/components/QuasarButton.vue b/src/components/QuasarButton.vue deleted file mode 100644 index 8e2e33ce8..000000000 --- a/src/components/QuasarButton.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/components/QuasarDialog.vue b/src/components/QuasarDialog.vue deleted file mode 100644 index 770dc3789..000000000 --- a/src/components/QuasarDialog.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/src/components/QuasarDrawer.vue b/src/components/QuasarDrawer.vue deleted file mode 100644 index 5d85f72b4..000000000 --- a/src/components/QuasarDrawer.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - diff --git a/src/components/QuasarPageSticky.vue b/src/components/QuasarPageSticky.vue deleted file mode 100644 index f281a973a..000000000 --- a/src/components/QuasarPageSticky.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/src/components/QuasarTooltip.vue b/src/components/QuasarTooltip.vue deleted file mode 100644 index c4905667c..000000000 --- a/src/components/QuasarTooltip.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/src/components/UserPanel.vue b/src/components/UserPanel.vue new file mode 100644 index 000000000..2e357698c --- /dev/null +++ b/src/components/UserPanel.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/__tests__/QuasarButton.spec.js b/src/components/__tests__/QuasarButton.spec.js deleted file mode 100644 index aabc1cf61..000000000 --- a/src/components/__tests__/QuasarButton.spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import { mount } from '@cypress/vue'; -import QuasarButton from '../QuasarButton.vue'; - -describe('QuasarButton', () => { - it('renders a message', () => { - const label = 'Hello there'; - mount(QuasarButton, { - props: { - label, - }, - }); - - cy.dataCy('button').should('contain', label); - }); - - it('renders another message', () => { - const label = 'Will this work?'; - mount(QuasarButton, { - props: { - label, - }, - }); - - cy.dataCy('button').should('contain', label); - }); - - it('should have a `positive` color', () => { - mount(QuasarButton); - - cy.dataCy('button').should('have.backgroundColor', 'var(--q-positive)').should('have.color', 'white'); - }); - - it('should emit `test` upon click', () => { - mount(QuasarButton); - - cy.dataCy('button') - .click() - .should(() => { - expect(Cypress.vueWrapper.emitted('test')).to.have.length(1); - }); - }); -}); diff --git a/src/components/__tests__/QuasarDialog.spec.js b/src/components/__tests__/QuasarDialog.spec.js deleted file mode 100644 index 931e2916c..000000000 --- a/src/components/__tests__/QuasarDialog.spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { mount } from '@cypress/vue'; -import DialogWrapper from 'app/test/cypress/wrappers/DialogWrapper.vue'; -import QuasarDialog from '../QuasarDialog.vue'; - -describe('QuasarDialog', () => { - it('should show a dialog with a message', () => { - const message = 'Hello, I am a dialog'; - mount(DialogWrapper, { - props: { - component: QuasarDialog, - componentProps: { - message, - }, - }, - }); - cy.dataCy('dialog').should('exist').should('contain', message); - }); - - it('should close a dialog when clikcing ok', () => { - // The dialog is still visible from the previous test - cy.dataCy('dialog').should('exist').dataCy('ok-button').click(); - cy.dataCy('dialog').should('not.exist'); - }); -}); diff --git a/src/components/__tests__/QuasarDrawer.spec.js b/src/components/__tests__/QuasarDrawer.spec.js deleted file mode 100644 index e6bbcc984..000000000 --- a/src/components/__tests__/QuasarDrawer.spec.js +++ /dev/null @@ -1,15 +0,0 @@ -import { mount } from '@cypress/vue'; -import LayoutContainer from 'app/test/cypress/wrappers/LayoutContainer.vue'; -import QuasarDrawer from '../QuasarDrawer.vue'; - -describe('QuasarDrawer', () => { - it('should show a drawer', () => { - mount(LayoutContainer, { - props: { - component: QuasarDrawer, - }, - }); - cy.dataCy('drawer').should('exist').dataCy('button').should('not.be.visible'); - cy.get('.q-scrollarea .scroll').scrollTo('bottom', { duration: 500 }).dataCy('button').should('be.visible'); - }); -}); diff --git a/src/components/__tests__/QuasarPageSticky.spec.js b/src/components/__tests__/QuasarPageSticky.spec.js deleted file mode 100644 index 2e989a597..000000000 --- a/src/components/__tests__/QuasarPageSticky.spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import { mount } from '@cypress/vue'; -import LayoutContainer from 'app/test/cypress/wrappers/LayoutContainer.vue'; -import QuasarPageSticky from '../QuasarPageSticky.vue'; - -describe('QuasarPageSticky', () => { - it('should show a sticky at the bottom-right of the page', () => { - mount(LayoutContainer, { - props: { - component: QuasarPageSticky, - title: 'Test', - }, - }); - - cy.dataCy('button') - .should('be.visible') - .should(($el) => { - const rect = $el[0].getBoundingClientRect(); - expect(rect.bottom).to.equal(window.innerHeight - 18); - expect(rect.right).to.equal(window.innerWidth - 18); - }); - }); -}); diff --git a/src/components/__tests__/QuasarTooltip.spec.js b/src/components/__tests__/QuasarTooltip.spec.js deleted file mode 100644 index f5941a31e..000000000 --- a/src/components/__tests__/QuasarTooltip.spec.js +++ /dev/null @@ -1,11 +0,0 @@ -import { mount } from '@cypress/vue'; -import QuasarTooltip from '../QuasarTooltip.vue'; - -describe('QuasarTooltip', () => { - it('should show a tooltip', () => { - mount(QuasarTooltip); - - cy.dataCy('button').trigger('mouseover'); - cy.dataCy('tooltip').contains('Here I am!'); - }); -}); diff --git a/src/components/__tests__/UserPanel.spec.js b/src/components/__tests__/UserPanel.spec.js new file mode 100644 index 000000000..de2962985 --- /dev/null +++ b/src/components/__tests__/UserPanel.spec.js @@ -0,0 +1,28 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-jest'; +import { mount } from '@vue/test-utils'; +import { i18n } from 'src/boot/i18n'; +import UserPanel from '../UserPanel.vue'; + +// Specify here Quasar config you'll need to test your component +installQuasarPlugin(); +const routerPushMock = jest.fn(); + +jest.mock('vue-router', () => ({ + useRouter: () => ({ + push: routerPushMock, + }), +})); + +describe('UserPanel', () => { + it('should have the function logout defined', () => { + const wrapper = mount(UserPanel, { + global: { + plugins: [i18n] + } + }); + const { vm } = wrapper; + + expect(vm.logout).toBeDefined(); + }); +}); diff --git a/src/composables/useRole.js b/src/composables/useRole.js new file mode 100644 index 000000000..e44827be1 --- /dev/null +++ b/src/composables/useRole.js @@ -0,0 +1,18 @@ +/* import store from '@/store'; + +export function useRole() { + function hasAny(roles: string[]): boolean { + const roleStore: string[] = store.state.roles; + + for (const role of roles) { + if (roleStore.indexOf(role) !== -1) return true; + } + + return false; + } + + return { + hasAny, + }; +} + */ diff --git a/src/composables/useSession.js b/src/composables/useSession.js new file mode 100644 index 000000000..3a60c33b9 --- /dev/null +++ b/src/composables/useSession.js @@ -0,0 +1,45 @@ +import { useState } from './useState'; + +export function useSession() { + function getToken() { + const localToken = localStorage.getItem('token'); + const sessionToken = sessionStorage.getItem('token'); + + return localToken || sessionToken || ''; + } + + function setToken(data) { + if (data.keepLogin) { + localStorage.setItem('token', data.token); + } else { + sessionStorage.setItem('token', data.token); + } + } + + function destroy() { + localStorage.removeItem('token'); + sessionStorage.getItem('token'); + + const { setUser } = useState(); + + setUser({ + id: 0, + username: '', + nickname: '', + }); + } + + function isLoggedIn() { + const localToken = localStorage.getItem('token'); + const sessionToken = sessionStorage.getItem('token'); + + return !!(localToken || sessionToken); + } + + return { + getToken, + setToken, + destroy, + isLoggedIn, + }; +} diff --git a/src/composables/useState.js b/src/composables/useState.js new file mode 100644 index 000000000..df121398e --- /dev/null +++ b/src/composables/useState.js @@ -0,0 +1,46 @@ +import { ref, computed } from 'vue'; + +const user = ref({ + id: 0, + username: '', + nickname: '', +}); + +const roles = ref([]); + +export function useState() { + function getUser() { + return computed(() => { + return { + id: user.value.id, + username: user.value.username, + nickname: user.value.nickname, + }; + }); + } + + function setUser(data) { + user.value = { + id: data.id, + username: data.username, + nickname: data.nickname, + }; + } + + function getRoles() { + return computed(() => { + return roles.value; + }); + } + + function setRoles(data) { + roles.value = data; + } + + return { + getUser, + setUser, + getRoles, + setRoles, + }; +} diff --git a/src/filters/index.js b/src/filters/index.js new file mode 100644 index 000000000..24a25ad68 --- /dev/null +++ b/src/filters/index.js @@ -0,0 +1,5 @@ +import toLowerCase from './toLowerCase'; + +export default { + toLowerCase, +}; diff --git a/src/filters/toLowerCase.js b/src/filters/toLowerCase.js new file mode 100644 index 000000000..c81b39423 --- /dev/null +++ b/src/filters/toLowerCase.js @@ -0,0 +1,3 @@ +export default function toLowerCase(value) { + return value.toLowerCase(); +} diff --git a/src/i18n/en-US/index.js b/src/i18n/en-US/index.js deleted file mode 100644 index 3601f69bc..000000000 --- a/src/i18n/en-US/index.js +++ /dev/null @@ -1,7 +0,0 @@ -// This is just an example, -// so you can safely delete all default props below - -export default { - failed: 'Action failed', - success: 'Action was successful', -}; diff --git a/src/i18n/en/index.js b/src/i18n/en/index.js new file mode 100644 index 000000000..9df5cbf17 --- /dev/null +++ b/src/i18n/en/index.js @@ -0,0 +1,35 @@ +export default { + globals: { + lang: { + es: 'Spanish', + en: 'English', + }, + }, + errors: { + statusUnauthorized: 'Access denied', + statusInternalServerError: 'An internal server error has ocurred', + }, + login: { + title: 'Login', + username: 'Username', + password: 'Password', + submit: 'Log in', + keepLogin: 'Keep me logged in', + loginSuccess: 'You have successfully logged in', + loginError: 'Invalid username or password', + }, + customer: {}, + components: { + topbar: {}, + userPanel: { + settings: 'Settings', + logOut: 'Log Out', + }, + }, + pages: { + logIn: 'Log In', + dashboard: 'Dashboard', + customers: 'Customers', + list: 'List', + }, +}; diff --git a/src/i18n/es/index.js b/src/i18n/es/index.js new file mode 100644 index 000000000..e0be0d849 --- /dev/null +++ b/src/i18n/es/index.js @@ -0,0 +1,29 @@ +export default { + globals: { + lang: { + es: 'Español', + en: 'Inglés', + }, + }, + errors: { + statusUnauthorized: 'Acceso denegado', + statusInternalServerError: 'Ha ocurrido un error interno del servidor', + }, + login: { + title: 'Iniciar sesión', + username: 'Nombre de usuario', + password: 'Contraseña', + submit: 'Iniciar sesión', + keepLogin: 'Mantener sesión iniciada', + loginSuccess: 'Inicio de sesión correcto', + loginError: 'Nombre de usuario o contraseña incorrectos', + }, + customer: {}, + components: { + topbar: {}, + userPanel: { + settings: 'Configuración', + logOut: 'Cerrar sesión', + }, + }, +}; diff --git a/src/i18n/index.js b/src/i18n/index.js index 3d51c7a85..7344be49a 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -1,5 +1,7 @@ -import enUS from './en-US'; +import en from './en'; +import es from './es'; export default { - 'en-US': enUS, + en: en, + es: es, }; diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 48fb17ca0..e56ddc1ac 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -1,95 +1,89 @@ + + - + \ No newline at end of file diff --git a/src/pages/Customer/Card/CustomerCard.vue b/src/pages/Customer/Card/CustomerCard.vue new file mode 100644 index 000000000..6517a1f85 --- /dev/null +++ b/src/pages/Customer/Card/CustomerCard.vue @@ -0,0 +1,15 @@ + diff --git a/src/pages/Customer/CustomerLayout.vue b/src/pages/Customer/CustomerLayout.vue new file mode 100644 index 000000000..2785996d8 --- /dev/null +++ b/src/pages/Customer/CustomerLayout.vue @@ -0,0 +1,3 @@ + diff --git a/src/pages/Customer/List.vue b/src/pages/Customer/List.vue new file mode 100644 index 000000000..47960b007 --- /dev/null +++ b/src/pages/Customer/List.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/pages/Dashboard/Dashboard.vue b/src/pages/Dashboard/Dashboard.vue new file mode 100644 index 000000000..f47438953 --- /dev/null +++ b/src/pages/Dashboard/Dashboard.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/src/pages/IndexPage.vue b/src/pages/IndexPage.vue deleted file mode 100644 index d1beaae57..000000000 --- a/src/pages/IndexPage.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/src/pages/Login/Login.vue b/src/pages/Login/Login.vue new file mode 100644 index 000000000..9cd5f94c7 --- /dev/null +++ b/src/pages/Login/Login.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/src/pages/Login/__tests__/Login.spec.js b/src/pages/Login/__tests__/Login.spec.js new file mode 100644 index 000000000..7baf937d7 --- /dev/null +++ b/src/pages/Login/__tests__/Login.spec.js @@ -0,0 +1,66 @@ +import { jest, describe, expect, it } from '@jest/globals'; +import { mount } from '@vue/test-utils'; +import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-jest'; +import { i18n } from 'src/boot/i18n'; +import { Notify } from 'quasar'; +import axios from 'axios'; +import Login from '../Login.vue'; + +// Specify here Quasar config you'll need to test your component +installQuasarPlugin({ + plugins: { + Notify + } +}); +const routerPushMock = jest.fn(); + +jest.mock('vue-router', () => ({ + useRouter: () => ({ + push: routerPushMock, + }), +})); + + +describe('Login', () => { + it('should successfully set the token into session', async () => { + const wrapper = mount(Login, { + global: { + plugins: [i18n] + } + }); + const { vm } = wrapper; + + jest.spyOn(axios, 'post').mockResolvedValue({ data: { token: 'token' } }); + jest.spyOn(vm.quasar, 'notify') + + expect(vm.session.getToken()).toEqual(''); + + await vm.onSubmit(); + + expect(vm.session.getToken()).toEqual('token'); + expect(vm.quasar.notify).toHaveBeenCalledWith(expect.objectContaining( + { 'type': 'positive' } + )); + vm.session.destroy(); + }); + + it('should not set the token into session if any error occurred', async () => { + const wrapper = mount(Login, { + global: { + plugins: [i18n] + } + }); + const { vm } = wrapper; + + jest.spyOn(axios, 'post').mockRejectedValue(new Error('error')); + jest.spyOn(vm.quasar, 'notify') + + expect(vm.session.getToken()).toEqual(''); + + await vm.onSubmit(); + + expect(vm.quasar.notify).toHaveBeenCalledWith(expect.objectContaining( + { 'type': 'negative' } + )); + }); +}); diff --git a/src/pages/ErrorNotFound.vue b/src/pages/NotFound.vue similarity index 54% rename from src/pages/ErrorNotFound.vue rename to src/pages/NotFound.vue index a1f75611d..65b91a3b8 100644 --- a/src/pages/ErrorNotFound.vue +++ b/src/pages/NotFound.vue @@ -5,15 +5,16 @@
Oops. Nothing here...
- + - diff --git a/src/router/routes.js b/src/router/routes.js index fe1db6000..f0d941d69 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -1,16 +1,49 @@ const routes = [ { - path: '/', - component: () => import('layouts/MainLayout.vue'), - children: [{ path: '', component: () => import('pages/IndexPage.vue') }], + path: '/login', + name: 'Login', + meta: { title: 'logIn' }, + component: () => import('../pages/Login/Login.vue'), }, - - // Always leave this as last one, - // but you can also remove it { - path: '/:catchAll(.*)*', - component: () => import('pages/ErrorNotFound.vue'), + path: '/', + name: 'Main', + component: () => import('../layouts/MainLayout.vue'), + redirect: { name: 'Dashboard' }, + children: [ + { + path: '/dashboard', + name: 'Dashboard', + meta: { title: 'dashboard' }, + component: () => import('../pages/Dashboard/Dashboard.vue'), + }, + { + path: '/customer', + name: 'Customer', + meta: { title: 'customers' }, + component: () => import('../pages/Customer/CustomerLayout.vue'), + redirect: { name: 'List' }, + children: [ + { + path: 'list', + name: 'List', + meta: { title: 'list' }, + component: () => import('../pages/Customer/List.vue'), + }, + { + path: ':id', + name: 'Card', + component: () => import('../pages/Customer/Card/CustomerCard.vue'), + }, + ], + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('../pages/NotFound.vue'), + }, + ], }, ]; -export default routes; +export default routes; \ No newline at end of file