feat(smartCard): added smart-card component
gitea/salix-front/pipeline/head This commit looks good
Details
gitea/salix-front/pipeline/head This commit looks good
Details
This commit is contained in:
parent
69a4be1318
commit
9e1d86e6bd
|
@ -1,19 +1,150 @@
|
|||
<script setup>
|
||||
import axios from 'axios';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const $props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
default: new Array(),
|
||||
required: true,
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-list {
|
||||
width: 100%;
|
||||
max-width: 60em;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
width: 100%;
|
||||
|
||||
h5 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
||||
});
|
||||
});
|
|
@ -40,6 +40,12 @@ export default {
|
|||
list: 'List',
|
||||
createCustomer: 'Create customer',
|
||||
basicData: 'Basic Data'
|
||||
},
|
||||
list: {
|
||||
phone: 'Phone',
|
||||
email: 'Email',
|
||||
customerOrders: 'Display customer orders',
|
||||
moreOptions: 'More options'
|
||||
}
|
||||
},
|
||||
ticket: {
|
||||
|
@ -56,5 +62,10 @@ export default {
|
|||
settings: 'Settings',
|
||||
logOut: 'Log Out',
|
||||
},
|
||||
smartCard: {
|
||||
noData: 'No data to display',
|
||||
openCard: 'View card',
|
||||
openSummary: 'Open summary'
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -11,7 +11,7 @@ export default {
|
|||
favoriteModules: 'Módulos favoritos',
|
||||
theme: 'Tema',
|
||||
logOut: 'Cerrar sesión',
|
||||
dataSaved: 'Datos guardados',
|
||||
dataSaved: 'Datos guardados'
|
||||
},
|
||||
moduleIndex: {
|
||||
allModules: 'Todos los módulos'
|
||||
|
@ -40,6 +40,12 @@ export default {
|
|||
list: 'Listado',
|
||||
createCustomer: 'Crear cliente',
|
||||
basicData: 'Datos básicos'
|
||||
},
|
||||
list: {
|
||||
phone: 'Teléfono',
|
||||
email: 'Email',
|
||||
customerOrders: 'Mostrar órdenes del cliente',
|
||||
moreOptions: 'Más opciones'
|
||||
}
|
||||
},
|
||||
ticket: {
|
||||
|
@ -56,5 +62,10 @@ export default {
|
|||
settings: 'Configuración',
|
||||
logOut: 'Cerrar sesión',
|
||||
},
|
||||
smartCard: {
|
||||
noData: 'Sin datos que mostrar',
|
||||
openCard: 'Ver ficha',
|
||||
openSummary: 'Abrir detalles'
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
<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>
|
|
@ -1,123 +1,10 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import SmartCard from 'src/components/SmartCard.vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
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];
|
||||
}
|
||||
const { t } = useI18n();
|
||||
|
||||
function navigate(id) {
|
||||
router.push({ path: `/customer/${id}` });
|
||||
|
@ -126,172 +13,54 @@ function navigate(id) {
|
|||
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div>
|
||||
<q-infinite-scroll @load="onLoad" :offset="100" style="height: 1000px">
|
||||
<q-table
|
||||
:columns="columns"
|
||||
:rows="customers"
|
||||
row-key="id"
|
||||
v-model:pagination="pagination"
|
||||
:loading="loading"
|
||||
@request="onRequest"
|
||||
binary-state-sort
|
||||
:grid="true"
|
||||
:card-container-class="['column', 'items-center', 'q-gutter-y-md', 'card']"
|
||||
:hide-pagination="true"
|
||||
>
|
||||
<template #loading>
|
||||
<q-inner-loading showing color="orange" />
|
||||
</template>
|
||||
<template #top-right>
|
||||
<q-btn round flat dense size="md" icon="download">
|
||||
<q-tooltip>Export to CSV</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn round flat dense size="md" icon="grid_view" @click="swapView()">
|
||||
<q-tooltip>Swap View</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
<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">
|
||||
<smart-card url="/api/Clients" sort-by="id DESC" @on-navigate="navigate" auto-load>
|
||||
<template #labels="{ row }">
|
||||
<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-label caption>{{ t('customer.list.email') }}</q-item-label>
|
||||
<q-item-label>{{ 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-label caption>{{ t('customer.list.phone') }}</q-item-label>
|
||||
<q-item-label>{{ row.phone }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<q-btn color="grey-7" round flat icon="more_vert">
|
||||
<q-tooltip>{{ t('customer.list.moreOptions') }}</q-tooltip>
|
||||
<q-menu cover auto-close>
|
||||
<q-list>
|
||||
<q-item clickable>
|
||||
<q-item-section>Action 1</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="add" />
|
||||
</q-item-section>
|
||||
<q-item-section>Add a note</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable>
|
||||
<q-item-section>Action 2</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="history" />
|
||||
</q-item-section>
|
||||
<q-item-section>Display customer history</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-card>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-infinite-scroll>
|
||||
|
||||
<!-- <q-card v-for="customer in customers" :key="customer.id" 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">{{ customer.name }}</div>
|
||||
<q-item-label caption>@{{ customer.username }}</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>{{ 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 flat round color="orange" icon="arrow_circle_right" @click="navigate(row.id)">
|
||||
<q-tooltip>{{ t('components.smartCard.openCard') }}</q-tooltip>
|
||||
</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-btn flat round color="grey-7" icon="preview">
|
||||
<q-tooltip>{{ t('components.smartCard.openSummary') }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat round color="grey-7" icon="vn:ticket">
|
||||
<q-tooltip>{{ t('customer.list.customerOrders') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</smart-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 60em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -11,12 +11,17 @@ installQuasarPlugin({
|
|||
}
|
||||
});
|
||||
|
||||
export function createWrapper(component) {
|
||||
const wrapper = mount(component, {
|
||||
export function createWrapper(component, options) {
|
||||
const mountOptions = {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (options instanceof Object)
|
||||
Object.assign(mountOptions, options)
|
||||
|
||||
const wrapper = mount(component, mountOptions);
|
||||
const vm = wrapper.vm;
|
||||
|
||||
return { vm, wrapper };
|
||||
|
|
Loading…
Reference in New Issue