Compare commits
3 commits
767d34f8b7
...
55cef1128e
Author | SHA1 | Date | |
---|---|---|---|
55cef1128e | |||
6e38ff7ac7 | |||
3a8fa8cdcf |
9 changed files with 423 additions and 188 deletions
133
web/src/components/AsyncLoader.vue
Normal file
133
web/src/components/AsyncLoader.vue
Normal file
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div class="async-wrapper" :class="{ 'loaded': loaded }">
|
||||
<div class="deferred">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="loader-wrapper">
|
||||
<div class="loader-ellipsis">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'AsyncLoader',
|
||||
props: {
|
||||
loaded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.async-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.async-wrapper > .deferred {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.async-wrapper.loaded > .deferred {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.async-wrapper > .loader-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.async-wrapper > .loader-wrapper > .loader-ellipsis {
|
||||
color: #17a2b8;
|
||||
}
|
||||
|
||||
.async-wrapper.loaded > .loader-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.async-wrapper > .loader-wrapper > .loader-ellipsis,
|
||||
.async-wrapper > .loader-wrapper > .loader-ellipsis div {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.async-wrapper > .loader-wrapper > .loader-ellipsis {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.async-wrapper > .loader-wrapper > .loader-ellipsis div {
|
||||
position: absolute;
|
||||
top: 33.33333px;
|
||||
width: 13.33333px;
|
||||
height: 13.33333px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
}
|
||||
|
||||
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(1) {
|
||||
left: 8px;
|
||||
animation: loader-ellipsis1 0.6s infinite;
|
||||
}
|
||||
|
||||
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(2) {
|
||||
left: 8px;
|
||||
animation: loader-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(3) {
|
||||
left: 32px;
|
||||
animation: loader-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
.async-wrapper > .loader-wrapper > .loader-ellipsis div:nth-child(4) {
|
||||
left: 56px;
|
||||
animation: loader-ellipsis3 0.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes loader-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(24px, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -29,16 +29,7 @@
|
|||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline mr-1" v-if="hasPermissions">
|
||||
<input
|
||||
class="form-control w-100"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
@input="searchEventItems($event.target.value)"
|
||||
disabled
|
||||
>
|
||||
</form>
|
||||
<SearchBox v-if="hasPermissions" class="mt-1 my-lg-auto my-xl-auto w-100 d-inline mr-1"/>
|
||||
<div class="custom-control-inline mr-1" v-if="hasPermissions">
|
||||
<div class="btn-group btn-group-toggle mr-1" v-if="isItemView()">
|
||||
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')">
|
||||
|
@ -103,9 +94,13 @@
|
|||
|
||||
<script>
|
||||
import {mapState, mapActions, mapMutations, mapGetters} from 'vuex';
|
||||
import SearchBox from "@/components/inputs/SearchBox.vue";
|
||||
|
||||
export default {
|
||||
name: 'Navbar',
|
||||
components: {
|
||||
SearchBox
|
||||
},
|
||||
data: () => ({
|
||||
views: [
|
||||
{'title': 'items', 'path': 'items'},
|
||||
|
@ -122,7 +117,7 @@ export default {
|
|||
...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions", "layout", "route"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['changeEvent', 'changeView', 'searchEventItems']),
|
||||
...mapActions(['changeEvent', 'changeView']),
|
||||
...mapMutations(['logout']),
|
||||
navigateTo(link) {
|
||||
if (this.route.path !== link)
|
||||
|
|
|
@ -40,10 +40,10 @@
|
|||
<div class="">
|
||||
<textarea placeholder="add comment..." v-model="newComment" class="form-control">
|
||||
</textarea>
|
||||
<button class="btn btn-primary float-right" @click="addCommentAndClear">
|
||||
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
|
||||
<font-awesome-icon icon="comment"/>
|
||||
Save Comment
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
@ -58,10 +58,10 @@
|
|||
<div>
|
||||
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
|
||||
</textarea>
|
||||
<button class="btn btn-primary float-right" @click="sendMailAndClear">
|
||||
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
|
||||
<font-awesome-icon icon="envelope"/>
|
||||
Send Mail
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
@ -77,11 +77,12 @@ import {mapActions, mapGetters} from "vuex";
|
|||
import TimelineAssignment from "@/components/TimelineAssignment.vue";
|
||||
import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue";
|
||||
import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
|
||||
import AsyncButton from "@/components/inputs/AsyncButton.vue";
|
||||
|
||||
export default {
|
||||
name: 'Timeline',
|
||||
components: {
|
||||
TimelineShippingVoucher,
|
||||
TimelineShippingVoucher, AsyncButton,
|
||||
TimelineRelatedItem, TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail
|
||||
},
|
||||
props: {
|
||||
|
@ -103,18 +104,15 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchShippingVouchers']),
|
||||
sendMailAndClear: function () {
|
||||
this.$emit('sendMail', this.newMail);
|
||||
...mapActions(['sendMail', 'postComment']),
|
||||
sendMailAndClear: async function () {
|
||||
await this.sendMail(this.newMail);
|
||||
this.newMail = "";
|
||||
},
|
||||
addCommentAndClear: function () {
|
||||
this.$emit('addComment', this.newComment);
|
||||
addCommentAndClear: async function () {
|
||||
await this.postComment(this.newComment);
|
||||
this.newComment = "";
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchShippingVouchers();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
47
web/src/components/inputs/AsyncButton.vue
Normal file
47
web/src/components/inputs/AsyncButton.vue
Normal file
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<button @click.stop="handleClick" :disabled="disabled">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"
|
||||
:class="{'d-none': !disabled}"></span>
|
||||
<span class="ml-2" :class="{'d-none': !disabled}">In Progress...</span>
|
||||
<span :class="{'d-none': disabled}"><slot></slot></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'AsyncButton',
|
||||
data() {
|
||||
return {
|
||||
disabled: false,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
task: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async handleClick() {
|
||||
console.log("AsyncButton.handleClick() called");
|
||||
if (this.task && typeof this.task === 'function') {
|
||||
this.disabled = true;
|
||||
try {
|
||||
await this.task();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.disabled = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spinner-border {
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
</style>
|
43
web/src/components/inputs/SearchBox.vue
Normal file
43
web/src/components/inputs/SearchBox.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<input
|
||||
class="form-control w-100"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
v-model="search_query"
|
||||
@keyup.enter="dispatchSearch"
|
||||
>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'SearchBox',
|
||||
data() {
|
||||
return {
|
||||
search_query: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['getActiveView'])
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['searchEventItems', 'searchEventTickets']),
|
||||
isItemView() {
|
||||
return this.getActiveView === 'items' || this.getActiveView === 'item';
|
||||
},
|
||||
isTicketView() {
|
||||
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket';
|
||||
},
|
||||
dispatchSearch() {
|
||||
if (this.isItemView()) {
|
||||
this.searchEventItems(this.search_query);
|
||||
} else if (this.isTicketView()) {
|
||||
this.searchEventTickets(this.search_query);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -23,6 +23,7 @@ const store = createStore({
|
|||
|
||||
lastEvent: '37C3',
|
||||
lastUsed: {},
|
||||
searchQuery: '',
|
||||
remember: false,
|
||||
user: {
|
||||
username: null,
|
||||
|
@ -355,10 +356,9 @@ const store = createStore({
|
|||
}
|
||||
},
|
||||
async searchEventItems({commit, getters, state}, query) {
|
||||
const foo = utf8.encode(query);
|
||||
const bar = base64.encode(foo);
|
||||
const encoded_query = base64.encode(utf8.encode(query));
|
||||
|
||||
const {data, success} = await http.get(`/2/${getters.getEventSlug}/items/${bar}/`, state.user.token);
|
||||
const {data, success} = await http.get(`/2/${getters.getEventSlug}/items/${encoded_query}/`, state.user.token);
|
||||
if (data && success)
|
||||
commit('replaceLoadedItems', data);
|
||||
},
|
||||
|
@ -407,6 +407,13 @@ const store = createStore({
|
|||
if (data && success)
|
||||
commit('replaceTickets', data);
|
||||
},
|
||||
async searchEventTickets({commit, getters, state}, query) {
|
||||
const encoded_query = base64.encode(utf8.encode(query));
|
||||
|
||||
const {data, success} = await http.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`, state.user.token);
|
||||
if (data && success)
|
||||
commit('replaceTickets', data);
|
||||
},
|
||||
async sendMail({commit, dispatch, state}, {id, message}) {
|
||||
const {data, success} = await http.post(`/2/tickets/${id}/reply/`, {message}, state.user.token);
|
||||
if (data && success) {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<template>
|
||||
<AsyncLoader :loaded="loadedItems.length > 0">
|
||||
<div class="container-fluid px-xl-5 mt-3">
|
||||
<Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()">
|
||||
<template #body>
|
||||
|
@ -24,7 +25,8 @@
|
|||
<template #actions="{ item }">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-success"
|
||||
@click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
|
||||
@click.stop="confirm('return Item?') && markItemReturned(item)"
|
||||
title="returned">
|
||||
<font-awesome-icon icon="check"/>
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click.stop="openEditingModalWith(item)" title="edit">
|
||||
|
@ -60,10 +62,12 @@
|
|||
@click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
|
||||
<font-awesome-icon icon="check"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item)" title="edit">
|
||||
<button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item)"
|
||||
title="edit">
|
||||
<font-awesome-icon icon="edit"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" @click.stop="confirm('delete Item?') && deleteItem(item)"
|
||||
<button class="btn btn-outline-danger"
|
||||
@click.stop="confirm('delete Item?') && deleteItem(item)"
|
||||
title="delete">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
</button>
|
||||
|
@ -72,6 +76,7 @@
|
|||
</div>
|
||||
</Cards>
|
||||
</div>
|
||||
</AsyncLoader>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -82,6 +87,7 @@ import EditItem from '@/components/EditItem';
|
|||
import {mapActions, mapGetters, mapState} from 'vuex';
|
||||
import Lightbox from '../components/Lightbox';
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import AsyncLoader from "@/components/AsyncLoader.vue";
|
||||
|
||||
export default {
|
||||
name: 'Items',
|
||||
|
@ -89,7 +95,7 @@ export default {
|
|||
lightboxHash: null,
|
||||
editingItem: null,
|
||||
}),
|
||||
components: {AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem},
|
||||
components: {AsyncLoader, AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem},
|
||||
computed: {
|
||||
...mapState(['loadedItems']),
|
||||
...mapGetters(['layout']),
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<template>
|
||||
<AsyncLoader :loaded="ticket.id">
|
||||
<div class="container-fluid px-xl-5 mt-3">
|
||||
<div class="row">
|
||||
<div class="col-xl-8 offset-xl-2">
|
||||
|
@ -59,16 +60,18 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AsyncLoader>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters, mapState} from 'vuex';
|
||||
import Timeline from "@/components/Timeline.vue";
|
||||
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
|
||||
import AsyncLoader from "@/components/AsyncLoader.vue";
|
||||
|
||||
export default {
|
||||
name: 'Ticket',
|
||||
components: {ClipboardButton, Timeline},
|
||||
components: {AsyncLoader, ClipboardButton, Timeline},
|
||||
data() {
|
||||
return {
|
||||
selected_state: null,
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
<template>
|
||||
<AsyncLoader :loaded="tickets.length > 0">
|
||||
<div class="container-fluid px-xl-5 mt-3">
|
||||
<div class="row">
|
||||
<div class="col-xl-8 offset-xl-2">
|
||||
<Table
|
||||
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to']"
|
||||
:items="tickets"
|
||||
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to', 'actions', 'actions2']"
|
||||
:items="tickets.map(formatTicket)"
|
||||
:keyName="'id'"
|
||||
v-if="layout === 'table'"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<template v-slot:actions="{item}">
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
|
||||
@click.prevent="gotoDetail(item)">
|
||||
|
@ -46,6 +47,7 @@
|
|||
</template>
|
||||
</CollapsableCards>
|
||||
</div>
|
||||
</AsyncLoader>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -56,10 +58,11 @@ import {mapActions, mapGetters, mapState} from 'vuex';
|
|||
import Lightbox from '../components/Lightbox';
|
||||
import Table from '@/components/Table';
|
||||
import CollapsableCards from "@/components/CollapsableCards.vue";
|
||||
import AsyncLoader from "@/components/AsyncLoader.vue";
|
||||
|
||||
export default {
|
||||
name: 'Tickets',
|
||||
components: {Lightbox, Table, Cards, Modal, EditItem, CollapsableCards},
|
||||
components: {AsyncLoader, Lightbox, Table, Cards, Modal, EditItem, CollapsableCards},
|
||||
computed: {
|
||||
...mapState(['tickets']),
|
||||
...mapGetters(['stateInfo', 'getEventSlug', 'layout']),
|
||||
|
|
Loading…
Reference in a new issue