feat(smartCard): added smart-card component
gitea/salix-front/pipeline/head This commit looks good Details

This commit is contained in:
Joan Sanchez 2022-06-21 07:42:33 +02:00
parent 69a4be1318
commit 9e1d86e6bd
7 changed files with 324 additions and 294 deletions

View File

@ -1,19 +1,150 @@
<script setup> <script setup>
import axios from 'axios';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const $props = defineProps({ const $props = defineProps({
columns: { url: {
type: Array, type: String,
default: new Array(), default: '',
required: true, },
autoLoad: {
type: Boolean,
default: false,
},
sortBy: {
type: String,
default: '',
},
rowsPerPage: {
type: Number,
default: 10,
},
offset: {
type: Number,
default: 500,
}, },
}); });
onMounted(); defineEmits(['onNavigate']);
console.log($props); 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;
// Loading status
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 = totalRows();
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 (totalRows() === 0) return done(false);
pagination.value.page = pagination.value.page + 1;
await fetch();
const endOfPages = !hasMoreData.value;
done(endOfPages);
}
function totalRows() {
return rows.value.length;
}
</script> </script>
<template> <template>
<q-table v-bind="$attrs"></q-table> <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> </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,101 @@
import { jest, describe, expect, it, beforeAll } from '@jest/globals';
import { createWrapper, axios, flushPromises } 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', () => {
let vm;
beforeAll(() => {
const options = {
attrs: {
url: '/api/customers',
sortBy: 'id DESC'
}
};
vm = createWrapper(SmartCard, options).vm;
});
it('should call to the fetch() method and set the data on the rows property', async () => {
jest.spyOn(axios, 'get').mockResolvedValue({
data: [
{ id: 1, name: 'Tony Stark' },
{ id: 2, name: 'Jessica Jones' },
{ id: 3, name: 'Bruce Wayne' },
]
});
const expectedPath = '/api/customers';
const expectedOptions = {
params: {
filter: {
order: 'id DESC',
limit: 10,
skip: 0
}
}
}
//vm.pagination.page = 2;
await vm.fetch();
await flushPromises();
expect(axios.get).toHaveBeenCalledWith(expectedPath, expectedOptions);
expect(vm.rows.length).toEqual(3);
vm.rows = []; // Clear
});
it('should test', async () => {
jest.spyOn(axios, 'get').mockResolvedValue({
data: [
{ id: 1, name: 'Tony Stark' },
{ id: 2, name: 'Jessica Jones' },
{ id: 3, name: 'Bruce Wayne' },
]
});
const expectedPath = '/api/customers';
const expectedOptions = {
params: {
filter: {
order: 'id DESC',
limit: 10,
skip: 10
}
}
}
//await vm.fetch();
//await flushPromises();
//expect(axios.get).toHaveBeenCalledWith(expectedPath, expectedOptions);
//expect(vm.rows.length).toEqual(3);
vm.pagination.page = 2;
await vm.fetch();
await flushPromises();
expect(axios.get).toHaveBeenCalledWith(expectedPath, expectedOptions);
expect(vm.rows.length).toEqual(3);
});
});

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,123 +1,10 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { useI18n } from 'vue-i18n';
import axios from 'axios';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import SmartCard from 'src/components/SmartCard.vue';
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const fabPos = ref([18, 18]);
const draggingFab = ref(false);
const gridView = ref(true);
const loading = ref(false);
const pagination = ref({
sortBy: 'id ASC',
descending: false,
page: 1,
rowsPerPage: 5,
});
const columns = [
{
name: 'id',
label: 'ID',
align: 'right',
field: (row) => row.id,
sortable: true,
},
{
name: 'name',
label: 'Name',
align: 'left',
field: (row) => row.name,
sortable: true,
},
{
name: 'phone',
label: 'Phone',
align: 'left',
field: (row) => row.phone,
sortable: true,
},
{
name: 'city',
label: 'City',
align: 'left',
field: (row) => row.city,
sortable: true,
},
{
name: 'email',
label: 'Email',
align: 'left',
field: (row) => row.email,
sortable: true,
},
];
const customers = ref([]);
async function totalRows() {
const { data } = await axios.get('/api/Clients/count');
return data.count;
}
async function onRequest(props) {
const { page, rowsPerPage, sortBy, descending } = props.pagination;
// Loading status
loading.value = true;
const sort = descending ? `${sortBy} DESC` : `${sortBy} ASC`;
const filter = {
order: sort,
limit: rowsPerPage,
skip: rowsPerPage * (page - 1),
};
const { data } = await axios.get('/api/Clients', {
params: { filter },
});
for (const row of data) {
customers.value.push(row);
}
//customers.value = newData;
pagination.value.rowsNumber = await totalRows();
pagination.value.page = page;
pagination.value.rowsPerPage = rowsPerPage;
pagination.value.sortBy = sortBy;
pagination.value.descending = descending;
loading.value = false;
}
function swapView() {
gridView.value = !gridView.value;
}
onMounted(() => {
onRequest({ pagination: pagination.value });
});
async function onLoad(index, done) {
console.log('scroll:', index);
pagination.value.page = pagination.value.page + 1;
await onRequest({ pagination: pagination.value });
done();
//done(true);
}
function moveFab(ev) {
draggingFab.value = ev.isFirst !== true && ev.isFinal !== true;
fabPos.value = [fabPos.value[0] - ev.delta.x, fabPos.value[1] - ev.delta.y];
}
function navigate(id) { function navigate(id) {
router.push({ path: `/customer/${id}` }); router.push({ path: `/customer/${id}` });
@ -126,172 +13,54 @@ function navigate(id) {
<template> <template>
<q-page class="q-pa-md"> <q-page class="q-pa-md">
<div> <smart-card url="/api/Clients" sort-by="id DESC" @on-navigate="navigate" auto-load>
<q-infinite-scroll @load="onLoad" :offset="100" style="height: 1000px"> <template #labels="{ row }">
<q-table <q-list>
:columns="columns" <q-item class="q-pa-none">
:rows="customers" <q-item-section>
row-key="id" <q-item-label caption>{{ t('customer.list.email') }}</q-item-label>
v-model:pagination="pagination" <q-item-label>{{ row.email }}</q-item-label>
:loading="loading" </q-item-section>
@request="onRequest" </q-item>
binary-state-sort <q-item class="q-pa-none">
:grid="true" <q-item-section>
:card-container-class="['column', 'items-center', 'q-gutter-y-md', 'card']" <q-item-label caption>{{ t('customer.list.phone') }}</q-item-label>
:hide-pagination="true" <q-item-label>{{ row.phone }}</q-item-label>
> </q-item-section>
<template #loading> </q-item>
<q-inner-loading showing color="orange" /> </q-list>
</template> </template>
<template #top-right> <template #actions="{ row }">
<q-btn round flat dense size="md" icon="download"> <q-btn color="grey-7" round flat icon="more_vert">
<q-tooltip>Export to CSV</q-tooltip> <q-tooltip>{{ t('customer.list.moreOptions') }}</q-tooltip>
</q-btn> <q-menu cover auto-close>
<q-btn round flat dense size="md" icon="grid_view" @click="swapView()"> <q-list>
<q-tooltip>Swap View</q-tooltip> <q-item clickable>
</q-btn> <q-item-section avatar>
</template> <q-icon name="add" />
<template #item="props">
<q-card class="card">
<q-item v-ripple class="q-pa-none items-start cursor-pointer q-hoverable">
<q-item-section class="q-pa-md">
<div class="text-h6">{{ props.row.name }}</div>
<q-item-label caption>#{{ props.row.id }}</q-item-label>
<div class="q-mt-md">
<q-list>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>Email</q-item-label>
<q-item-label>{{ props.row.email }}</q-item-label>
</q-item-section>
</q-item>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>Phone</q-item-label>
<q-item-label>{{ props.row.phone }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</q-item-section> </q-item-section>
<q-btn color="grey-7" round flat icon="more_vert"> <q-item-section>Add a note</q-item-section>
<q-menu cover auto-close>
<q-list>
<q-item clickable>
<q-item-section>Action 1</q-item-section>
</q-item>
<q-item clickable>
<q-item-section>Action 2</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
<q-separator vertical />
<q-card-actions vertical class="justify-between">
<q-btn
flat
round
color="orange"
icon="arrow_circle_right"
@click="navigate(props.row.id)"
/>
<q-btn flat round color="accent" icon="preview" />
<q-btn flat round color="accent" icon="vn:ticket" />
</q-card-actions>
</q-item> </q-item>
</q-card> <q-item clickable>
</template> <q-item-section avatar>
</q-table> <q-icon name="history" />
</q-infinite-scroll> </q-item-section>
<q-item-section>Display customer history</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
<!-- <q-card v-for="customer in customers" :key="customer.id" class="card"> <q-btn flat round color="orange" icon="arrow_circle_right" @click="navigate(row.id)">
<q-tooltip>{{ t('components.smartCard.openCard') }}</q-tooltip>
<q-item v-ripple class="q-pa-none items-start cursor-pointer q-hoverable"> </q-btn>
<q-item-section class="q-pa-md"> <q-btn flat round color="grey-7" icon="preview">
<div class="text-h6">{{ customer.name }}</div> <q-tooltip>{{ t('components.smartCard.openSummary') }}</q-tooltip>
<q-item-label caption>@{{ customer.username }}</q-item-label> </q-btn>
<div class="q-mt-md"> <q-btn flat round color="grey-7" icon="vn:ticket">
<q-list> <q-tooltip>{{ t('customer.list.customerOrders') }}</q-tooltip>
<q-item class="q-pa-none"> </q-btn>
<q-item-section> </template>
<q-item-label caption>Email</q-item-label> </smart-card>
<q-item-label>{{ customer.email }}</q-item-label>
</q-item-section>
</q-item>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>Phone</q-item-label>
<q-item-label>{{ customer.phone }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</q-item-section>
<q-btn color="grey-7" round flat icon="more_vert">
<q-menu cover auto-close>
<q-list>
<q-item clickable>
<q-item-section>Action 1</q-item-section>
</q-item>
<q-item clickable>
<q-item-section>Action 2</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
<q-separator vertical />
<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="accent" icon="preview" />
<q-btn flat round color="accent" icon="vn:ticket" />
<q-card-actions>
<q-btn
color="grey"
round
flat
dense
:icon="customer.expanded.value ? 'keyboard_arrow_up' : 'keyboard_arrow_down'"
@click="customer.expanded.value = !customer.expanded.value"
/>
</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-sticky position="bottom-right" :offset="fabPos">
<q-fab
icon="add"
direction="up"
color="light-green-6"
:disable="draggingFab"
v-touch-pan.prevent.mouse="moveFab"
>
<q-fab-action @click="onClick" color="light-green-4" icon="person_add" :disable="draggingFab" />
<q-fab-action @click="onClick" color="light-green-4" icon="mail" :disable="draggingFab" />
</q-fab>
</q-page-sticky>
</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 };