0
0
Fork 0

Merge pull request '4183- Smart Card component' (#18) from 4183-smart-list into dev

Reviewed-on: verdnatura/salix-front#18
This commit is contained in:
Carlos Jimenez Ruiz 2022-06-21 12:48:33 +00:00
commit 10157230d6
10 changed files with 2926 additions and 1018 deletions

3313
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,10 +18,10 @@
"test:e2e:ci": "cross-env E2E_TEST=true start-test \"quasar dev\" http-get://localhost:8080 \"cypress run\"" "test:e2e:ci": "cross-env E2E_TEST=true start-test \"quasar dev\" http-get://localhost:8080 \"cypress run\""
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.0.0", "@quasar/extras": "^1.14.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"quasar": "^2.6.0", "quasar": "^2.7.1",
"vue": "^3.0.0", "vue": "^3.0.0",
"vue-i18n": "^9.0.0", "vue-i18n": "^9.0.0",
"vue-router": "^4.0.0" "vue-router": "^4.0.0"
@ -29,17 +29,17 @@
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.13.14", "@babel/eslint-parser": "^7.13.14",
"@intlify/vue-i18n-loader": "^4.1.0", "@intlify/vue-i18n-loader": "^4.1.0",
"@quasar/app-webpack": "^3.0.0", "@quasar/app-webpack": "^3.5.3",
"@quasar/quasar-app-extension-testing-e2e-cypress": "^4.0.1", "@quasar/quasar-app-extension-testing-e2e-cypress": "^4.1.2",
"@quasar/quasar-app-extension-testing-unit-jest": "^3.0.0-alpha.9", "@quasar/quasar-app-extension-testing-unit-jest": "^3.0.0-alpha.10",
"eslint": "^8.10.0", "eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.1.0",
"eslint-plugin-cypress": "^2.11.3", "eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-jest": "^25.2.2", "eslint-plugin-jest": "^25.2.2",
"eslint-plugin-vue": "^8.5.0", "eslint-plugin-vue": "^8.5.0",
"eslint-webpack-plugin": "^3.1.1", "eslint-webpack-plugin": "^3.1.1",
"prettier": "^2.5.1", "jest-junit": "^13.0.0",
"jest-junit": "^13.0.0" "prettier": "^2.5.1"
}, },
"browserslist": [ "browserslist": [
"last 10 Chrome versions", "last 10 Chrome versions",

View File

@ -0,0 +1,145 @@
<script setup>
import axios from 'axios';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const $props = defineProps({
url: {
type: String,
default: '',
},
autoLoad: {
type: Boolean,
default: false,
},
sortBy: {
type: String,
default: '',
},
rowsPerPage: {
type: Number,
default: 10,
},
offset: {
type: Number,
default: 500,
},
});
defineEmits(['onNavigate']);
const isLoading = ref(false);
const hasMoreData = ref(false);
const pagination = ref({
sortBy: $props.sortBy,
rowsPerPage: $props.rowsPerPage,
page: 1,
});
const rows = ref([]);
onMounted(() => {
if ($props.autoLoad) fetch();
});
async function fetch() {
const { page, rowsPerPage, sortBy, descending } = pagination.value;
isLoading.value = true;
const filter = {
limit: rowsPerPage,
skip: rowsPerPage * (page - 1),
};
if (sortBy) filter.order = sortBy;
const { data } = await axios.get($props.url, {
params: { filter },
});
hasMoreData.value = data.length === rowsPerPage;
for (const row of data) rows.value.push(row);
pagination.value.rowsNumber = rows.value.length;
pagination.value.page = page;
pagination.value.rowsPerPage = rowsPerPage;
pagination.value.sortBy = sortBy;
pagination.value.descending = descending;
isLoading.value = false;
}
async function onLoad(...params) {
const done = params[1];
if (rows.value.length === 0) return done(false);
pagination.value.page = pagination.value.page + 1;
await fetch();
const endOfPages = !hasMoreData.value;
done(endOfPages);
}
</script>
<template>
<q-infinite-scroll @load="onLoad" :offset="offset" class="column items-center">
<div class="card-list q-gutter-y-md">
<q-card class="card" v-for="row of rows" :key="row.id">
<q-item v-ripple class="q-pa-none items-start cursor-pointer q-hoverable">
<q-item-section class="q-pa-md" @click="$emit('onNavigate', row.id)">
<slot name="header" :row="row">
<div class="text-h6">{{ row.name }}</div>
<q-item-label caption>#{{ row.id }}</q-item-label>
</slot>
<slot name="labels" :row="row"></slot>
</q-item-section>
<q-separator vertical />
<q-card-actions vertical class="justify-between">
<slot name="actions" :row="row">
<q-btn
flat
round
color="orange"
icon="arrow_circle_right"
@click="$emit('onNavigate', row.id)"
>
<q-tooltip>{{ t('components.smartCard.openCard') }}</q-tooltip>
</q-btn>
<q-btn flat round color="grey-7" icon="preview">
<q-tooltip>{{ t('components.smartCard.openSummary') }}</q-tooltip>
</q-btn>
</slot>
</q-card-actions>
</q-item>
</q-card>
<div v-if="!rows.length && !isLoading" class="info-row q-pa-md text-center">
<h5>
{{ t('components.smartCard.noData') }}
</h5>
</div>
<div v-if="isLoading" class="info-row q-pa-md text-center">
<q-spinner color="orange" size="md" />
</div>
</div>
</q-infinite-scroll>
</template>
<style lang="scss" scoped>
.card-list {
width: 100%;
max-width: 60em;
}
.info-row {
width: 100%;
h5 {
margin: 0;
}
}
</style>

View File

@ -0,0 +1,151 @@
import { jest, describe, expect, it, beforeAll } from '@jest/globals';
import { createWrapper, axios } from 'app/tests/jest/jestHelpers';
import SmartCard from '../SmartCard.vue';
const mockPush = jest.fn();
jest.mock('vue-router', () => ({
useRouter: () => ({
push: mockPush,
currentRoute: { value: 'myCurrentRoute' }
}),
}));
describe('SmartCard', () => {
const expectedUrl = '/api/customers';
let vm;
beforeAll(() => {
const options = {
attrs: {
url: expectedUrl,
sortBy: 'id DESC',
rowsPerPage: 3
}
};
vm = createWrapper(SmartCard, options).vm;
jest.spyOn(axios, 'get').mockResolvedValue({
data: [
{ id: 1, name: 'Tony Stark' },
{ id: 2, name: 'Jessica Jones' },
{ id: 3, name: 'Bruce Wayne' },
]
});
});
afterEach(() => {
vm.rows = [];
vm.pagination.page = 1;
vm.hasMoreData = true;
})
describe('fetch()', () => {
it('should call to the fetch() method and set the data on the rows property', async () => {
const expectedOptions = {
params: {
filter: {
order: 'id DESC',
limit: 3,
skip: 0
}
}
};
await vm.fetch();
expect(axios.get).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(vm.rows.length).toEqual(3);
});
it('should call to the fetch() method and then call it again to paginate', async () => {
const expectedOptions = {
params: {
filter: {
order: 'id DESC',
limit: 3,
skip: 0
}
}
};
await vm.fetch();
expect(axios.get).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(vm.rows.length).toEqual(3);
const expectedOptionsPaginated = {
params: {
filter: {
order: 'id DESC',
limit: 3,
skip: 3
}
}
};
vm.pagination.page = 2;
await vm.fetch();
expect(axios.get).toHaveBeenCalledWith(expectedUrl, expectedOptionsPaginated);
expect(vm.rows.length).toEqual(6);
});
});
describe('onLoad()', () => {
it('should call to the done() callback and not increment the pagination', async () => {
const index = 1;
const done = jest.fn();
await vm.onLoad(index, done);
expect(vm.pagination.page).toEqual(1);
expect(done).toHaveBeenCalledWith(false);
});
it('should increment the pagination and then call to the done() callback', async () => {
vm.rows = [
{ id: 1, name: 'Tony Stark' },
{ id: 2, name: 'Jessica Jones' },
{ id: 3, name: 'Bruce Wayne' },
];
expect(vm.pagination.page).toEqual(1);
const index = 1;
const done = jest.fn();
await vm.onLoad(index, done);
expect(vm.pagination.page).toEqual(2);
expect(done).toHaveBeenCalledWith(false);
});
it('should call to the done() callback with true as argument to finish pagination', async () => {
jest.spyOn(axios, 'get').mockResolvedValue({
data: [
{ id: 1, name: 'Tony Stark' },
{ id: 2, name: 'Jessica Jones' }
]
});
vm.rows = [
{ id: 1, name: 'Tony Stark' },
{ id: 2, name: 'Jessica Jones' },
{ id: 3, name: 'Bruce Wayne' },
];
expect(vm.pagination.page).toEqual(1);
const index = 1;
const done = jest.fn();
vm.hasMoreData = false;
await vm.onLoad(index, done);
expect(vm.pagination.page).toEqual(2);
expect(done).toHaveBeenCalledWith(true);
});
});
});

View File

@ -40,6 +40,12 @@ export default {
list: 'List', list: 'List',
createCustomer: 'Create customer', createCustomer: 'Create customer',
basicData: 'Basic Data' basicData: 'Basic Data'
},
list: {
phone: 'Phone',
email: 'Email',
customerOrders: 'Display customer orders',
moreOptions: 'More options'
} }
}, },
ticket: { ticket: {
@ -56,5 +62,10 @@ export default {
settings: 'Settings', settings: 'Settings',
logOut: 'Log Out', logOut: 'Log Out',
}, },
smartCard: {
noData: 'No data to display',
openCard: 'View card',
openSummary: 'Open summary'
}
}, },
}; };

View File

@ -11,7 +11,7 @@ export default {
favoriteModules: 'Módulos favoritos', favoriteModules: 'Módulos favoritos',
theme: 'Tema', theme: 'Tema',
logOut: 'Cerrar sesión', logOut: 'Cerrar sesión',
dataSaved: 'Datos guardados', dataSaved: 'Datos guardados'
}, },
moduleIndex: { moduleIndex: {
allModules: 'Todos los módulos' allModules: 'Todos los módulos'
@ -40,6 +40,12 @@ export default {
list: 'Listado', list: 'Listado',
createCustomer: 'Crear cliente', createCustomer: 'Crear cliente',
basicData: 'Datos básicos' basicData: 'Datos básicos'
},
list: {
phone: 'Teléfono',
email: 'Email',
customerOrders: 'Mostrar órdenes del cliente',
moreOptions: 'Más opciones'
} }
}, },
ticket: { ticket: {
@ -56,5 +62,10 @@ export default {
settings: 'Configuración', settings: 'Configuración',
logOut: 'Cerrar sesión', logOut: 'Cerrar sesión',
}, },
smartCard: {
noData: 'Sin datos que mostrar',
openCard: 'Ver ficha',
openSummary: 'Abrir detalles'
}
}, },
}; };

View File

@ -1,3 +1,5 @@
<template> <template>
<q-card>Basic Data</q-card> <q-page class="q-pa-md">
<q-card class="q-pa-md">Basic Data</q-card>
</q-page>
</template> </template>

View File

@ -1,24 +1,138 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useState } from 'src/composables/useState'; import { useState } from 'src/composables/useState';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import axios from 'axios';
const state = useState(); const state = useState();
const router = useRouter(); const router = useRouter();
onMounted(async () => {
await fetch();
});
const entityId = computed(function () { const entityId = computed(function () {
return router.currentRoute.value.params.id; return router.currentRoute.value.params.id;
}); });
const customer = ref({});
async function fetch() {
const { data } = await axios.get(`/api/Clients/${entityId.value}`);
if (data) customer.value = data;
}
</script> </script>
<template> <template>
<q-drawer v-model="state.drawer.value" show-if-above :width="200" :breakpoint="500"> <q-drawer v-model="state.drawer.value" show-if-above :width="256" :breakpoint="500">
<q-scroll-area class="fit text-grey-8"> <q-scroll-area class="fit text-grey-8 descriptor">
<router-link :to="{ path: '/customer/list' }"> <div class="header bg-orange q-pa-sm">
<q-icon name="arrow_back" size="md" color="primary" /> <router-link :to="{ path: '/customer/list' }">
</router-link> <q-btn round flat dense size="md" icon="view_list" color="white">
<div>Customer ID: {{ entityId }}</div> <q-tooltip>Customer list</q-tooltip>
</q-btn>
</router-link>
<router-link :to="{ path: '/customer/list' }">
<q-btn round flat dense size="md" icon="launch" color="white">
<q-tooltip>Customer preview</q-tooltip>
</q-btn>
</router-link>
<q-btn round flat dense size="md" icon="more_vert" color="white">
<q-tooltip>More options</q-tooltip>
<q-menu>
<q-list>
<q-item clickable v-ripple>Option 1</q-item>
<q-item clickable v-ripple>Option 2</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<h5>{{ customer.name }}</h5>
<q-list>
<q-item>
<q-item-section>
<q-item-label caption>Customer ID</q-item-label>
<q-item-label>#{{ customer.id }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>Email</q-item-label>
<q-item-label>{{ customer.email }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-actions>
<q-btn size="md" icon="vn:ticket" color="orange">
<q-tooltip>Ticket list</q-tooltip>
</q-btn>
<q-btn size="md" icon="vn:invoice-out" color="orange">
<q-tooltip>Invoice Out list</q-tooltip>
</q-btn>
<q-btn size="md" icon="vn:basketadd" color="orange">
<q-tooltip>Order list</q-tooltip>
</q-btn>
<q-btn size="md" icon="face" color="orange">
<q-tooltip>View user</q-tooltip>
</q-btn>
<q-btn size="md" icon="expand_more" color="orange">
<q-tooltip>More options</q-tooltip>
</q-btn>
</q-card-actions>
<q-separator />
<q-list>
<q-item :to="{ name: 'CustomerBasicData' }" clickable v-ripple active-class="text-orange">
<q-item-section avatar>
<q-icon name="person" />
</q-item-section>
<q-item-section>Basic data</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section avatar>
<q-icon name="notes" />
</q-item-section>
<q-item-section>Notes</q-item-section>
</q-item>
<q-expansion-item icon="more" label="More options" expand-icon-toggle expand-separator>
<q-list>
<q-item clickable v-ripple>
<q-item-section avatar>
<q-icon name="person" />
</q-item-section>
<q-item-section>Option</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
</q-list>
</q-scroll-area> </q-scroll-area>
</q-drawer> </q-drawer>
<q-page-container> <q-page-container>
<router-view></router-view> <router-view></router-view>
</q-page-container> </q-page-container>
</template> </template>
<style lang="scss" scoped>
.descriptor {
h5 {
margin: 0 15px;
}
.header {
display: flex;
justify-content: space-between;
}
.q-card__actions {
justify-content: center;
}
}
</style>

View File

@ -1,26 +1,10 @@
<script setup> <script setup>
import { ref } from 'vue'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const router = useRouter(); import SmartCard from 'src/components/SmartCard.vue';
const customers = [ const router = useRouter();
{ const { t } = useI18n();
id: 1101,
name: 'Bruce Wayne',
username: 'batman',
email: 'batman@gotham',
phone: '555-555-5555',
expanded: ref(false),
},
{
id: 1102,
name: 'James Gordon',
username: 'jamesgordon',
email: 'jamesgordon@gotham',
phone: '555-555-1111',
expanded: ref(false),
},
];
function navigate(id) { function navigate(id) {
router.push({ path: `/customer/${id}` }); router.push({ path: `/customer/${id}` });
@ -29,82 +13,54 @@ function navigate(id) {
<template> <template>
<q-page class="q-pa-md"> <q-page class="q-pa-md">
<div class="column items-center q-gutter-y-md"> <smart-card url="/api/Clients" sort-by="id DESC" @on-navigate="navigate" auto-load>
<q-card v-for="customer in customers" :key="customer.id" class="card"> <template #labels="{ row }">
<!-- v-ripple :to="{ path: '/dashboard' }" --> <q-list>
<q-item v-ripple class="q-pa-none items-start cursor-pointer q-hoverable"> <q-item class="q-pa-none">
<q-item-section class="q-pa-md"> <q-item-section>
<div class="text-h6">{{ customer.name }}</div> <q-item-label caption>{{ t('customer.list.email') }}</q-item-label>
<q-item-label caption>@{{ customer.username }}</q-item-label> <q-item-label>{{ row.email }}</q-item-label>
<div class="q-mt-md"> </q-item-section>
<q-list> </q-item>
<q-item class="q-pa-none"> <q-item class="q-pa-none">
<q-item-section> <q-item-section>
<q-item-label caption>Email</q-item-label> <q-item-label caption>{{ t('customer.list.phone') }}</q-item-label>
<q-item-label>{{ customer.email }}</q-item-label> <q-item-label>{{ row.phone }}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item class="q-pa-none"> </q-list>
<q-item-section> </template>
<q-item-label caption>Phone</q-item-label> <template #actions="{ row }">
<q-item-label>{{ customer.phone }}</q-item-label> <q-btn color="grey-7" round flat icon="more_vert">
</q-item-section> <q-tooltip>{{ t('customer.list.moreOptions') }}</q-tooltip>
</q-item> <q-menu cover auto-close>
</q-list> <q-list>
</div> <q-item clickable>
</q-item-section> <q-item-section avatar>
<q-btn color="grey-7" round flat icon="more_vert"> <q-icon name="add" />
<q-menu cover auto-close> </q-item-section>
<q-list> <q-item-section>Add a note</q-item-section>
<q-item clickable> </q-item>
<q-item-section>Action 1</q-item-section> <q-item clickable>
</q-item> <q-item-section avatar>
<q-item clickable> <q-icon name="history" />
<q-item-section>Action 2</q-item-section> </q-item-section>
</q-item> <q-item-section>Display customer history</q-item-section>
</q-list> </q-item>
</q-menu> </q-list>
</q-btn> </q-menu>
<q-separator vertical /> </q-btn>
<q-card-actions vertical class="justify-between">
<q-btn flat round color="orange" icon="arrow_circle_right" @click="navigate(customer.id)" /> <q-btn flat round color="orange" icon="arrow_circle_right" @click="navigate(row.id)">
<q-btn flat round color="accent" icon="preview" /> <q-tooltip>{{ t('components.smartCard.openCard') }}</q-tooltip>
<q-btn flat round color="accent" icon="vn:ticket" /> </q-btn>
<q-card-actions> <q-btn flat round color="grey-7" icon="preview">
<q-btn <q-tooltip>{{ t('components.smartCard.openSummary') }}</q-tooltip>
color="grey" </q-btn>
round <q-btn flat round color="grey-7" icon="vn:ticket">
flat <q-tooltip>{{ t('customer.list.customerOrders') }}</q-tooltip>
dense </q-btn>
:icon="customer.expanded.value ? 'keyboard_arrow_up' : 'keyboard_arrow_down'" </template>
@click="customer.expanded.value = !customer.expanded.value" </smart-card>
/>
</q-card-actions>
</q-card-actions>
</q-item>
<q-slide-transition>
<div v-show="customer.expanded.value">
<q-separator />
<q-card-section class="text-subitle2">
<q-list>
<q-item clickable>
<q-item-section>
<q-item-label>Address</q-item-label>
<q-item-label caption>Avenue 11</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</div>
</q-slide-transition>
</q-card>
</div>
</q-page> </q-page>
</template> </template>
<style lang="scss" scoped>
.card {
width: 100%;
max-width: 60em;
}
</style>

View File

@ -11,12 +11,17 @@ installQuasarPlugin({
} }
}); });
export function createWrapper(component) { export function createWrapper(component, options) {
const wrapper = mount(component, { const mountOptions = {
global: { global: {
plugins: [i18n] plugins: [i18n]
} }
}); };
if (options instanceof Object)
Object.assign(mountOptions, options)
const wrapper = mount(component, mountOptions);
const vm = wrapper.vm; const vm = wrapper.vm;
return { vm, wrapper }; return { vm, wrapper };