Skip to content
Snippets Groups Projects
Verified Commit 6a67bc6f authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Fix #171: dedicated endpoint to list import jobs, updated front-end

parent 4f2a325f
Branches
Tags
No related merge requests found
...@@ -2,6 +2,7 @@ from django.db.models import Count ...@@ -2,6 +2,7 @@ from django.db.models import Count
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from funkwhale_api.common import fields
from . import models from . import models
...@@ -28,6 +29,39 @@ class ArtistFilter(ListenableMixin): ...@@ -28,6 +29,39 @@ class ArtistFilter(ListenableMixin):
} }
class ImportBatchFilter(filters.FilterSet):
q = fields.SearchFilter(search_fields=[
'submitted_by__username',
'source',
])
class Meta:
model = models.ImportBatch
fields = {
'status': ['exact'],
'source': ['exact'],
'submitted_by': ['exact'],
}
class ImportJobFilter(filters.FilterSet):
q = fields.SearchFilter(search_fields=[
'batch__submitted_by__username',
'source',
])
class Meta:
model = models.ImportJob
fields = {
'batch': ['exact'],
'batch__status': ['exact'],
'batch__source': ['exact'],
'batch__submitted_by': ['exact'],
'status': ['exact'],
'source': ['exact'],
}
class AlbumFilter(ListenableMixin): class AlbumFilter(ListenableMixin):
listenable = filters.BooleanFilter(name='_', method='filter_listenable') listenable = filters.BooleanFilter(name='_', method='filter_listenable')
......
...@@ -6,6 +6,7 @@ from funkwhale_api.activity import serializers as activity_serializers ...@@ -6,6 +6,7 @@ from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.federation.models import LibraryTrack from funkwhale_api.federation.models import LibraryTrack
from funkwhale_api.federation.serializers import AP_CONTEXT from funkwhale_api.federation.serializers import AP_CONTEXT
from funkwhale_api.users.serializers import UserBasicSerializer
from . import models from . import models
...@@ -90,6 +91,7 @@ class TrackSerializerNested(LyricsMixin): ...@@ -90,6 +91,7 @@ class TrackSerializerNested(LyricsMixin):
files = TrackFileSerializer(many=True, read_only=True) files = TrackFileSerializer(many=True, read_only=True)
album = SimpleAlbumSerializer(read_only=True) album = SimpleAlbumSerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True) tags = TagSerializer(many=True, read_only=True)
class Meta: class Meta:
model = models.Track model = models.Track
fields = ('id', 'mbid', 'title', 'artist', 'files', 'album', 'tags', 'lyrics') fields = ('id', 'mbid', 'title', 'artist', 'files', 'album', 'tags', 'lyrics')
...@@ -108,6 +110,7 @@ class AlbumSerializerNested(serializers.ModelSerializer): ...@@ -108,6 +110,7 @@ class AlbumSerializerNested(serializers.ModelSerializer):
class ArtistSerializerNested(serializers.ModelSerializer): class ArtistSerializerNested(serializers.ModelSerializer):
albums = AlbumSerializerNested(many=True, read_only=True) albums = AlbumSerializerNested(many=True, read_only=True)
tags = TagSerializer(many=True, read_only=True) tags = TagSerializer(many=True, read_only=True)
class Meta: class Meta:
model = models.Artist model = models.Artist
fields = ('id', 'mbid', 'name', 'albums', 'tags') fields = ('id', 'mbid', 'name', 'albums', 'tags')
...@@ -121,18 +124,43 @@ class LyricsSerializer(serializers.ModelSerializer): ...@@ -121,18 +124,43 @@ class LyricsSerializer(serializers.ModelSerializer):
class ImportJobSerializer(serializers.ModelSerializer): class ImportJobSerializer(serializers.ModelSerializer):
track_file = TrackFileSerializer(read_only=True) track_file = TrackFileSerializer(read_only=True)
class Meta: class Meta:
model = models.ImportJob model = models.ImportJob
fields = ('id', 'mbid', 'batch', 'source', 'status', 'track_file', 'audio_file') fields = (
'id',
'mbid',
'batch',
'source',
'status',
'track_file',
'audio_file')
read_only_fields = ('status', 'track_file') read_only_fields = ('status', 'track_file')
class ImportBatchSerializer(serializers.ModelSerializer): class ImportBatchSerializer(serializers.ModelSerializer):
jobs = ImportJobSerializer(many=True, read_only=True) submitted_by = UserBasicSerializer(read_only=True)
class Meta: class Meta:
model = models.ImportBatch model = models.ImportBatch
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request') fields = (
read_only_fields = ('creation_date',) 'id',
'submitted_by',
'source',
'status',
'creation_date',
'import_request')
read_only_fields = (
'creation_date', 'submitted_by', 'source')
def to_representation(self, instance):
repr = super().to_representation(instance)
try:
repr['job_count'] = instance.job_count
except AttributeError:
# Queryset was not annotated
pass
return repr
class TrackActivitySerializer(activity_serializers.ModelSerializer): class TrackActivitySerializer(activity_serializers.ModelSerializer):
......
...@@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist ...@@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings from django.conf import settings
from django.db import models, transaction from django.db import models, transaction
from django.db.models.functions import Length from django.db.models.functions import Length
from django.db.models import Count
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
...@@ -99,14 +100,14 @@ class ImportBatchViewSet( ...@@ -99,14 +100,14 @@ class ImportBatchViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
queryset = ( queryset = (
models.ImportBatch.objects.all() models.ImportBatch.objects
.prefetch_related('jobs__track_file') .select_related()
.order_by('-creation_date')) .order_by('-creation_date')
.annotate(job_count=Count('jobs'))
)
serializer_class = serializers.ImportBatchSerializer serializer_class = serializers.ImportBatchSerializer
permission_classes = (permissions.DjangoModelPermissions, ) permission_classes = (permissions.DjangoModelPermissions, )
filter_class = filters.ImportBatchFilter
def get_queryset(self):
return super().get_queryset().filter(submitted_by=self.request.user)
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(submitted_by=self.request.user) serializer.save(submitted_by=self.request.user)
...@@ -119,13 +120,30 @@ class ImportJobPermission(HasModelPermission): ...@@ -119,13 +120,30 @@ class ImportJobPermission(HasModelPermission):
class ImportJobViewSet( class ImportJobViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
queryset = (models.ImportJob.objects.all()) queryset = (models.ImportJob.objects.all().select_related())
serializer_class = serializers.ImportJobSerializer serializer_class = serializers.ImportJobSerializer
permission_classes = (ImportJobPermission, ) permission_classes = (ImportJobPermission, )
filter_class = filters.ImportJobFilter
def get_queryset(self): @list_route(methods=['get'])
return super().get_queryset().filter(batch__submitted_by=self.request.user) def stats(self, request, *args, **kwargs):
qs = models.ImportJob.objects.all()
filterset = filters.ImportJobFilter(request.GET, queryset=qs)
qs = filterset.qs
qs = qs.values('status').order_by('status')
qs = qs.annotate(status_count=Count('status'))
data = {}
for row in qs:
data[row['status']] = row['status_count']
for s, _ in models.IMPORT_STATUS_CHOICES:
data.setdefault(s, 0)
data['count'] = sum([v for v in data.values()])
return Response(data)
def perform_create(self, serializer): def perform_create(self, serializer):
source = 'file://' + serializer.validated_data['audio_file'].name source = 'file://' + serializer.validated_data['audio_file'].name
...@@ -136,7 +154,8 @@ class ImportJobViewSet( ...@@ -136,7 +154,8 @@ class ImportJobViewSet(
) )
class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): class TrackViewSet(
TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
""" """
A simple ViewSet for viewing and editing accounts. A simple ViewSet for viewing and editing accounts.
""" """
......
...@@ -181,30 +181,6 @@ def test_can_import_whole_artist( ...@@ -181,30 +181,6 @@ def test_can_import_whole_artist(
assert job.source == row['source'] assert job.source == row['source']
def test_user_can_query_api_for_his_own_batches(
superuser_api_client, factories):
factories['music.ImportJob']()
job = factories['music.ImportJob'](
batch__submitted_by=superuser_api_client.user)
url = reverse('api:v1:import-batches-list')
response = superuser_api_client.get(url)
results = response.data
assert results['count'] == 1
assert results['results'][0]['jobs'][0]['mbid'] == job.mbid
def test_user_cannnot_access_other_batches(
superuser_api_client, factories):
factories['music.ImportJob']()
job = factories['music.ImportJob']()
url = reverse('api:v1:import-batches-list')
response = superuser_api_client.get(url)
results = response.data
assert results['count'] == 0
def test_user_can_create_an_empty_batch(superuser_api_client, factories): def test_user_can_create_an_empty_batch(superuser_api_client, factories):
url = reverse('api:v1:import-batches-list') url = reverse('api:v1:import-batches-list')
response = superuser_api_client.post(url) response = superuser_api_client.post(url)
......
...@@ -128,3 +128,46 @@ def test_can_create_import_from_federation_tracks( ...@@ -128,3 +128,46 @@ def test_can_create_import_from_federation_tracks(
assert batch.jobs.count() == 5 assert batch.jobs.count() == 5
for i, job in enumerate(batch.jobs.all()): for i, job in enumerate(batch.jobs.all()):
assert job.library_track == lts[i] assert job.library_track == lts[i]
def test_can_list_import_jobs(factories, superuser_api_client):
job = factories['music.ImportJob']()
url = reverse('api:v1:import-jobs-list')
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data['results'][0]['id'] == job.pk
def test_import_job_stats(factories, superuser_api_client):
job1 = factories['music.ImportJob'](status='pending')
job2 = factories['music.ImportJob'](status='errored')
url = reverse('api:v1:import-jobs-stats')
response = superuser_api_client.get(url)
expected = {
'errored': 1,
'pending': 1,
'finished': 0,
'skipped': 0,
'count': 2,
}
assert response.status_code == 200
assert response.data == expected
def test_import_job_stats_filter(factories, superuser_api_client):
job1 = factories['music.ImportJob'](status='pending')
job2 = factories['music.ImportJob'](status='errored')
url = reverse('api:v1:import-jobs-stats')
response = superuser_api_client.get(url, {'batch': job1.batch.pk})
expected = {
'errored': 0,
'pending': 1,
'finished': 0,
'skipped': 0,
'count': 1,
}
assert response.status_code == 200
assert response.data == expected
Import job and batch API and front-end have been improved with better performance,
pagination and additional filters (#171)
...@@ -4,31 +4,80 @@ ...@@ -4,31 +4,80 @@
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div> </div>
<div v-if="batch" class="ui vertical stripe segment"> <div v-if="batch" class="ui vertical stripe segment">
<div :class=" <table class="ui very basic table">
['ui', <tbody>
{'active': batch.status === 'pending'}, <tr>
{'warning': batch.status === 'pending'}, <td>
{'error': batch.status === 'errored'}, <strong>{{ $t('Import batch') }}</strong>
{'success': batch.status === 'finished'}, </td>
'progress']"> <td>
<div class="bar" :style="progressBarStyle"> #{{ batch.id }}
<div class="progress"></div> </td>
</tr>
<tr>
<td>
<strong>{{ $t('Launch date') }}</strong>
</td>
<td>
<human-date :date="batch.creation_date"></human-date>
</td>
</tr>
<tr v-if="batch.user">
<td>
<strong>{{ $t('Submitted by') }}</strong>
</td>
<td>
<username :username="batch.user.username" />
</td>
</tr>
<tr v-if="stats">
<td><strong>{{ $t('Pending') }}</strong></td>
<td>{{ stats.pending }}</td>
</tr>
<tr v-if="stats">
<td><strong>{{ $t('Skipped') }}</strong></td>
<td>{{ stats.skipped }}</td>
</tr>
<tr v-if="stats">
<td><strong>{{ $t('Errored') }}</strong></td>
<td>{{ stats.errored }}</td>
</tr>
<tr v-if="stats">
<td><strong>{{ $t('Finished') }}</strong></td>
<td>{{ stats.finished }}/{{ stats.count}}</td>
</tr>
</tbody>
</table>
<div class="ui inline form">
<div class="fields">
<div class="ui field">
<label>{{ $t('Search') }}</label>
<input type="text" v-model="jobFilters.search" placeholder="Search by source..." />
</div>
<div class="ui field">
<label>{{ $t('Status') }}</label>
<select class="ui dropdown" v-model="jobFilters.status">
<option :value="null">{{ $t('Any') }}</option>
<option :value="'pending'">{{ $t('Pending') }}</option>
<option :value="'errored'">{{ $t('Errored') }}</option>
<option :value="'finished'">{{ $t('Success') }}</option>
<option :value="'skipped'">{{ $t('Skipped') }}</option>
</select>
</div>
</div> </div>
<div v-if="batch.status === 'pending'" class="label">Importing {{ batch.jobs.length }} tracks...</div>
<div v-if="batch.status === 'finished'" class="label">Imported {{ batch.jobs.length }} tracks!</div>
</div> </div>
<table class="ui unstackable table"> <table v-if="jobResult" class="ui unstackable table">
<thead> <thead>
<tr> <tr>
<i18next tag="th" path="Job ID"/> <th>{{ $t('Job ID') }}</th>
<i18next tag="th" path="Recording MusicBrainz ID"/> <th>{{ $t('Recording MusicBrainz ID') }}</th>
<i18next tag="th" path="Source"/> <th>{{ $t('Source') }}</th>
<i18next tag="th" path="Status"/> <th>{{ $t('Status') }}</th>
<i18next tag="th" path="Track"/> <th>{{ $t('Track') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="job in batch.jobs"> <tr v-for="job in jobResult.results">
<td>{{ job.id }}</th> <td>{{ job.id }}</th>
<td> <td>
<a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a> <a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a>
...@@ -45,29 +94,64 @@ ...@@ -45,29 +94,64 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
<tfoot class="full-width">
<tr>
<th>
<pagination
v-if="jobResult && jobResult.results.length > 0"
@page-changed="selectPage"
:compact="true"
:current="jobFilters.page"
:paginate-by="jobFilters.paginateBy"
:total="jobResult.count"
></pagination>
</th>
<th v-if="jobResult && jobResult.results.length > 0">
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((jobFilters.page-1) * jobFilters.paginateBy) + 1 , end: ((jobFilters.page-1) * jobFilters.paginateBy) + jobResult.results.length, total: jobResult.count})}}
<th>
<th></th>
<th></th>
<th></th>
</tr>
</tfoot>
</table> </table>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import _ from 'lodash'
import axios from 'axios' import axios from 'axios'
import logger from '@/logging' import logger from '@/logging'
import Pagination from '@/components/Pagination'
const FETCH_URL = 'import-batches/'
export default { export default {
props: ['id'], props: ['id'],
components: {
Pagination
},
data () { data () {
return { return {
isLoading: true, isLoading: true,
batch: null, batch: null,
timeout: null stats: null,
jobResult: null,
timeout: null,
jobFilters: {
status: null,
source: null,
search: '',
paginateBy: 25,
page: 1
}
} }
}, },
created () { created () {
this.fetchData() let self = this
this.fetchData().then(() => {
self.fetchJobs()
self.fetchStats()
})
}, },
destroyed () { destroyed () {
if (this.timeout) { if (this.timeout) {
...@@ -78,9 +162,9 @@ export default { ...@@ -78,9 +162,9 @@ export default {
fetchData () { fetchData () {
var self = this var self = this
this.isLoading = true this.isLoading = true
let url = FETCH_URL + this.id + '/' let url = 'import-batches/' + this.id + '/'
logger.default.debug('Fetching batch "' + this.id + '"') logger.default.debug('Fetching batch "' + this.id + '"')
axios.get(url).then((response) => { return axios.get(url).then((response) => {
self.batch = response.data self.batch = response.data
self.isLoading = false self.isLoading = false
if (self.batch.status === 'pending') { if (self.batch.status === 'pending') {
...@@ -90,21 +174,58 @@ export default { ...@@ -90,21 +174,58 @@ export default {
) )
} }
}) })
}
},
computed: {
progress () {
return this.batch.jobs.filter(j => {
return j.status !== 'pending'
}).length * 100 / this.batch.jobs.length
}, },
progressBarStyle () { fetchStats () {
return 'width: ' + parseInt(this.progress) + '%' var self = this
let url = 'import-jobs/stats/'
axios.get(url, {params: {batch: self.id}}).then((response) => {
let old = self.stats
self.stats = response.data
self.isLoading = false
if (!_.isEqual(old, self.stats)) {
self.fetchJobs()
self.fetchData()
}
if (self.batch.status === 'pending') {
self.timeout = setTimeout(
self.fetchStats,
5000
)
}
})
},
fetchJobs () {
let params = {
batch: this.id,
page_size: this.jobFilters.paginateBy,
page: this.jobFilters.page,
q: this.jobFilters.search
}
if (this.jobFilters.status) {
params.status = this.jobFilters.status
}
if (this.jobFilters.source) {
params.source = this.jobFilters.source
}
let self = this
axios.get('import-jobs/', {params}).then((response) => {
self.jobResult = response.data
})
},
selectPage: function (page) {
this.jobFilters.page = page
} }
}, },
watch: { watch: {
id () { id () {
this.fetchData() this.fetchData()
},
jobFilters: {
handler () {
this.fetchJobs()
},
deep: true
} }
} }
} }
......
...@@ -2,76 +2,144 @@ ...@@ -2,76 +2,144 @@
<div v-title="'Import Batches'"> <div v-title="'Import Batches'">
<div class="ui vertical stripe segment"> <div class="ui vertical stripe segment">
<div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> <div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
<button <div class="ui inline form">
class="ui left floated labeled icon button" <div class="fields">
@click="fetchData(previousLink)" <div class="ui field">
:disabled="!previousLink"><i class="left arrow icon"></i><i18next path="Previous"/></button> <label>{{ $t('Search') }}</label>
<button <input type="text" v-model="filters.search" placeholder="Search by submitter, source..." />
class="ui right floated right labeled icon button" </div>
@click="fetchData(nextLink)" <div class="ui field">
:disabled="!nextLink"><i18next path="Next"/><i class="right arrow icon"></i></button> <label>{{ $t('Status') }}</label>
<div class="ui hidden clearing divider"></div> <select class="ui dropdown" v-model="filters.status">
<option :value="null">{{ $t('Any') }}</option>
<option :value="'pending'">{{ $t('Pending') }}</option>
<option :value="'errored'">{{ $t('Errored') }}</option>
<option :value="'finished'">{{ $t('Success') }}</option>
</select>
</div>
<div class="ui field">
<label>{{ $t('Import source') }}</label>
<select class="ui dropdown" v-model="filters.source">
<option :value="null">{{ $t('Any') }}</option>
<option :value="'shell'">{{ $t('CLI') }}</option>
<option :value="'api'">{{ $t('API') }}</option>
<option :value="'federation'">{{ $t('Federation') }}</option>
</select>
</div>
</div>
</div>
<div class="ui hidden clearing divider"></div> <div class="ui hidden clearing divider"></div>
<table v-if="results.length > 0" class="ui unstackable table"> <table v-if="result && result.results.length > 0" class="ui unstackable table">
<thead> <thead>
<tr> <tr>
<i18next tag="th" path="ID"/> <th>{{ $t('ID') }}</th>
<i18next tag="th" path="Launch date"/> <th>{{ $t('Launch date') }}</th>
<i18next tag="th" path="Jobs"/> <th>{{ $t('Jobs') }}</th>
<i18next tag="th" path="Status"/> <th>{{ $t('Status') }}</th>
<th>{{ $t('Source') }}</th>
<th>{{ $t('Submitted by') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="result in results"> <tr v-for="obj in result.results">
<td>{{ result.id }}</th> <td>{{ obj.id }}</th>
<td> <td>
<router-link :to="{name: 'library.import.batches.detail', params: {id: result.id }}"> <router-link :to="{name: 'library.import.batches.detail', params: {id: obj.id }}">
{{ result.creation_date }} <human-date :date="obj.creation_date"></human-date>
</router-link> </router-link>
</td> </td>
<td>{{ result.jobs.length }}</td> <td>{{ obj.job_count }}</td>
<td> <td>
<span <span
:class="['ui', {'yellow': result.status === 'pending'}, {'red': result.status === 'errored'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span> :class="['ui', {'yellow': obj.status === 'pending'}, {'red': obj.status === 'errored'}, {'green': obj.status === 'finished'}, 'label']">{{ obj.status }}
</td> </span>
</tr> </td>
</tbody> <td>{{ obj.source }}</td>
</table> <td><template v-if="obj.submitted_by">{{ obj.submitted_by.username }}</template></td>
</div> </tr>
</tbody>
<tfoot class="full-width">
<tr>
<th>
<pagination
v-if="result && result.results.length > 0"
@page-changed="selectPage"
:compact="true"
:current="filters.page"
:paginate-by="filters.paginateBy"
:total="result.count"
></pagination>
</th>
<th v-if="result && result.results.length > 0">
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((filters.page-1) * filters.paginateBy) + 1 , end: ((filters.page-1) * filters.paginateBy) + result.results.length, total: result.count})}}
<th>
<th></th>
<th></th>
<th></th>
</tr>
</tfoot>
</table>
</div>
</div> </div>
</template> </template>
<script> <script>
import axios from 'axios' import axios from 'axios'
import logger from '@/logging' import logger from '@/logging'
import Pagination from '@/components/Pagination'
const BATCHES_URL = 'import-batches/'
export default { export default {
components: {}, components: {
Pagination
},
data () { data () {
return { return {
results: [], result: null,
isLoading: false, isLoading: false,
nextLink: null, filters: {
previousLink: null status: null,
source: null,
search: '',
paginateBy: 25,
page: 1
}
} }
}, },
created () { created () {
this.fetchData(BATCHES_URL) this.fetchData()
}, },
methods: { methods: {
fetchData (url) { fetchData () {
let params = {
page_size: this.filters.paginateBy,
page: this.filters.page,
q: this.filters.search
}
if (this.filters.status) {
params.status = this.filters.status
}
if (this.filters.source) {
params.source = this.filters.source
}
var self = this var self = this
this.isLoading = true this.isLoading = true
logger.default.time('Loading import batches') logger.default.time('Loading import batches')
axios.get(url, {}).then((response) => { axios.get('import-batches/', {params}).then((response) => {
self.results = response.data.results self.result = response.data
self.nextLink = response.data.next
self.previousLink = response.data.previous
logger.default.timeEnd('Loading import batches') logger.default.timeEnd('Loading import batches')
self.isLoading = false self.isLoading = false
}) })
},
selectPage: function (page) {
this.filters.page = page
}
},
watch: {
filters: {
handler () {
this.fetchData()
},
deep: true
} }
} }
} }
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment