Browse Source

Merge pull request #1668 from doccano/fix/1666

Enable to paginate projects
pull/1670/head
Hiroki Nakayama 2 years ago
committed by GitHub
parent
commit
819d25f4ce
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 121 additions and 32 deletions
  1. 6
      backend/api/tests/api/test_project.py
  2. 6
      backend/api/views/project.py
  3. 61
      frontend/components/project/ProjectList.vue
  4. 13
      frontend/domain/models/project/project.ts
  5. 5
      frontend/domain/models/project/projectRepository.ts
  6. 26
      frontend/pages/projects/index.vue
  7. 10
      frontend/repositories/project/apiProjectRepository.ts
  8. 10
      frontend/services/application/project/projectApplicationService.ts
  9. 16
      frontend/services/application/project/projectData.ts

6
backend/api/tests/api/test_project.py

@ -15,13 +15,13 @@ class TestProjectList(CRUDMixin):
def test_return_projects_to_member(self): def test_return_projects_to_member(self):
for member in self.project.members: for member in self.project.members:
response = self.assert_fetch(member, status.HTTP_200_OK) response = self.assert_fetch(member, status.HTTP_200_OK)
project = response.data[0]
self.assertEqual(len(response.data), 1)
project = response.data['results'][0]
self.assertEqual(response.data['count'], 1)
self.assertEqual(project['id'], self.project.item.id) self.assertEqual(project['id'], self.project.item.id)
def test_does_not_return_project_to_non_member(self): def test_does_not_return_project_to_non_member(self):
response = self.assert_fetch(self.non_member, status.HTTP_200_OK) response = self.assert_fetch(self.non_member, status.HTTP_200_OK)
self.assertEqual(len(response.data), 0)
self.assertEqual(response.data['count'], 0)
class TestProjectCreate(CRUDMixin): class TestProjectCreate(CRUDMixin):

6
backend/api/views/project.py

@ -1,5 +1,6 @@
from django.conf import settings from django.conf import settings
from rest_framework import generics, status
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, generics, status
from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
@ -11,7 +12,8 @@ from ..serializers import ProjectPolymorphicSerializer
class ProjectList(generics.ListCreateAPIView): class ProjectList(generics.ListCreateAPIView):
serializer_class = ProjectPolymorphicSerializer serializer_class = ProjectPolymorphicSerializer
pagination_class = None
filter_backends = (DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
search_fields = ('name', 'description')
def get_permissions(self): def get_permissions(self):
if self.request.method == 'GET': if self.request.method == 'GET':

61
frontend/components/project/ProjectList.vue

@ -3,12 +3,15 @@
:value="value" :value="value"
:headers="headers" :headers="headers"
:items="items" :items="items"
:options.sync="options"
:server-items-length="total"
:search="search" :search="search"
:loading="isLoading" :loading="isLoading"
:loading-text="$t('generic.loading')" :loading-text="$t('generic.loading')"
:no-data-text="$t('vuetify.noDataAvailable')" :no-data-text="$t('vuetify.noDataAvailable')"
:footer-props="{ :footer-props="{
'showFirstLastPage': true, 'showFirstLastPage': true,
'items-per-page-options': [10, 50, 100],
'items-per-page-text': $t('vuetify.itemsPerPageText'), 'items-per-page-text': $t('vuetify.itemsPerPageText'),
'page-text': $t('dataset.pageText') 'page-text': $t('dataset.pageText')
}" }"
@ -36,19 +39,21 @@
</template> </template>
<template #[`item.tags`]="{ item }"> <template #[`item.tags`]="{ item }">
<v-chip <v-chip
v-for="tag in item.tags"
:key="tag.id"
outlined>{{tag.text}}
</v-chip>
v-for="tag in item.tags"
:key="tag.id"
outlined v-text="tag.text"
/>
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'
import Vue, { PropType } from 'vue'
import { mdiMagnify } from '@mdi/js' import { mdiMagnify } from '@mdi/js'
import { DataOptions } from 'vuetify/types'
import VueFilterDateFormat from '@vuejs-community/vue-filter-date-format' import VueFilterDateFormat from '@vuejs-community/vue-filter-date-format'
import VueFilterDateParse from '@vuejs-community/vue-filter-date-parse' import VueFilterDateParse from '@vuejs-community/vue-filter-date-parse'
import { ProjectDTO } from '~/services/application/project/projectData'
Vue.use(VueFilterDateFormat) Vue.use(VueFilterDateFormat)
Vue.use(VueFilterDateParse) Vue.use(VueFilterDateParse)
@ -60,19 +65,26 @@ export default Vue.extend({
required: true required: true
}, },
items: { items: {
type: Array,
type: Array as PropType<ProjectDTO[]>,
default: () => [], default: () => [],
required: true required: true
}, },
value: { value: {
type: Array,
type: Array as PropType<ProjectDTO[]>,
default: () => [], default: () => [],
required: true required: true
},
total: {
type: Number,
default: 0,
required: true
} }
}, },
data() { data() {
return { return {
search: '',
search: this.$route.query.q,
options: {} as DataOptions,
mdiMagnify mdiMagnify
} }
}, },
@ -87,6 +99,39 @@ export default Vue.extend({
{ text: 'Tags', value: 'tags'} { text: 'Tags', value: 'tags'}
] ]
} }
},
watch: {
options: {
handler() {
const self: any = this
self.updateQuery({
query: {
limit: self.options.itemsPerPage.toString(),
offset: ((self.options.page - 1) * self.options.itemsPerPage).toString(),
q: self.search
}
})
},
deep: true
},
search() {
const self: any = this
self.updateQuery({
query: {
limit: self.options.itemsPerPage.toString(),
offset: '0',
q: self.search
}
})
self.options.page = 1
}
},
methods: {
updateQuery(payload: any) {
this.$emit('update:query', payload)
}
} }
}) })
</script> </script>

13
frontend/domain/models/project/project.ts

@ -1,4 +1,5 @@
import { Expose } from 'class-transformer'
import "reflect-metadata"
import { Expose, Type } from 'class-transformer'
export type ProjectType = 'DocumentClassification' | 'SequenceLabeling' | 'Seq2seq' | 'IntentDetectionAndSlotFilling' | 'ImageClassification' | 'Speech2text' export type ProjectType = 'DocumentClassification' | 'SequenceLabeling' | 'Seq2seq' | 'IntentDetectionAndSlotFilling' | 'ImageClassification' | 'Speech2text'
@ -74,6 +75,16 @@ export class ProjectReadItem {
} }
} }
export class ProjectItemList {
count: number;
next: string | null;
prev: string | null;
@Type(() => ProjectReadItem)
@Expose({ name: 'results' })
items: ProjectReadItem[];
}
export class ProjectWriteItem { export class ProjectWriteItem {
constructor( constructor(
public id: number, public id: number,

5
frontend/domain/models/project/projectRepository.ts

@ -1,8 +1,9 @@
import { ProjectReadItem, ProjectWriteItem } from '~/domain/models/project/project'
import { ProjectReadItem, ProjectWriteItem, ProjectItemList } from '~/domain/models/project/project'
export type SearchOption = {[key: string]: string | (string | null)[]}
export interface ProjectRepository { export interface ProjectRepository {
list(): Promise<ProjectReadItem[]>
list({ limit, offset, q }: SearchOption): Promise<ProjectItemList>
findById(id: string): Promise<ProjectReadItem> findById(id: string): Promise<ProjectReadItem>

26
frontend/pages/projects/index.vue

@ -33,17 +33,20 @@
</v-card-title> </v-card-title>
<project-list <project-list
v-model="selected" v-model="selected"
:items="items"
:items="projects.items"
:is-loading="isLoading" :is-loading="isLoading"
/>
:total="projects.count"
@update:query="updateQuery"
/>
</v-card> </v-card>
</template> </template>
<script lang="ts"> <script lang="ts">
import _ from 'lodash'
import Vue from 'vue' import Vue from 'vue'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import ProjectList from '@/components/project/ProjectList.vue' import ProjectList from '@/components/project/ProjectList.vue'
import { ProjectDTO, ProjectWriteDTO } from '~/services/application/project/projectData'
import { ProjectDTO, ProjectWriteDTO, ProjectListDTO } from '~/services/application/project/projectData'
import FormDelete from '~/components/project/FormDelete.vue' import FormDelete from '~/components/project/FormDelete.vue'
import FormCreate from '~/components/project/FormCreate.vue' import FormCreate from '~/components/project/FormCreate.vue'
@ -82,7 +85,7 @@ export default Vue.extend({
allowOverlapping: false, allowOverlapping: false,
graphemeMode: false graphemeMode: false
} as ProjectWriteDTO, } as ProjectWriteDTO,
items: [] as ProjectDTO[],
projects: {} as ProjectListDTO,
selected: [] as ProjectDTO[], selected: [] as ProjectDTO[],
isLoading: false isLoading: false
} }
@ -90,7 +93,7 @@ export default Vue.extend({
async fetch() { async fetch() {
this.isLoading = true this.isLoading = true
this.items = await this.$services.project.list()
this.projects = await this.$services.project.list(this.$route.query)
this.isLoading = false this.isLoading = false
}, },
@ -101,6 +104,14 @@ export default Vue.extend({
}, },
}, },
watch: {
'$route.query': _.debounce(function() {
// @ts-ignore
this.$fetch()
}, 1000
),
},
methods: { methods: {
async create() { async create() {
const project = await this.$services.project.create(this.editedItem) const project = await this.$services.project.create(this.editedItem)
@ -114,12 +125,17 @@ export default Vue.extend({
this.editedItem = Object.assign({}, this.defaultItem) this.editedItem = Object.assign({}, this.defaultItem)
}) })
}, },
async remove() { async remove() {
await this.$services.project.bulkDelete(this.selected) await this.$services.project.bulkDelete(this.selected)
this.$fetch() this.$fetch()
this.dialogDelete = false this.dialogDelete = false
this.selected = [] this.selected = []
}, },
updateQuery(query: object) {
this.$router.push(query)
}
} }
}) })
</script> </script>

10
frontend/repositories/project/apiProjectRepository.ts

@ -1,7 +1,7 @@
import { plainToInstance } from 'class-transformer' import { plainToInstance } from 'class-transformer'
import ApiService from '@/services/api.service' import ApiService from '@/services/api.service'
import { ProjectRepository } from '@/domain/models/project/projectRepository'
import { ProjectReadItem, ProjectWriteItem } from '~/domain/models/project/project'
import { ProjectRepository, SearchOption } from '@/domain/models/project/projectRepository'
import { ProjectReadItem, ProjectWriteItem, ProjectItemList } from '~/domain/models/project/project'
export class APIProjectRepository implements ProjectRepository { export class APIProjectRepository implements ProjectRepository {
@ -9,10 +9,10 @@ export class APIProjectRepository implements ProjectRepository {
private readonly request = ApiService private readonly request = ApiService
) {} ) {}
async list(): Promise<ProjectReadItem[]> {
const url = `/projects`
async list({ limit = '10', offset = '0', q = '' }: SearchOption): Promise<ProjectItemList> {
const url = `/projects?limit=${limit}&offset=${offset}&q=${q}`
const response = await this.request.get(url) const response = await this.request.get(url)
return response.data.map((item: any) => plainToInstance(ProjectReadItem, item))
return plainToInstance(ProjectItemList, response.data)
} }
async findById(id: string): Promise<ProjectReadItem> { async findById(id: string): Promise<ProjectReadItem> {

10
frontend/services/application/project/projectApplicationService.ts

@ -1,5 +1,5 @@
import { ProjectDTO, ProjectWriteDTO } from './projectData'
import { ProjectRepository } from '~/domain/models/project/projectRepository'
import { ProjectDTO, ProjectWriteDTO, ProjectListDTO } from './projectData'
import { ProjectRepository, SearchOption } from '~/domain/models/project/projectRepository'
import { ProjectWriteItem } from '~/domain/models/project/project' import { ProjectWriteItem } from '~/domain/models/project/project'
@ -8,10 +8,10 @@ export class ProjectApplicationService {
private readonly repository: ProjectRepository private readonly repository: ProjectRepository
) {} ) {}
public async list(): Promise<ProjectDTO[]> {
public async list(options: SearchOption): Promise<ProjectListDTO> {
try { try {
const items = await this.repository.list()
return items.map(item => new ProjectDTO(item))
const items = await this.repository.list(options)
return new ProjectListDTO(items)
} catch(e: any) { } catch(e: any) {
throw new Error(e.response.data.detail) throw new Error(e.response.data.detail)
} }

16
frontend/services/application/project/projectData.ts

@ -1,4 +1,4 @@
import { ProjectReadItem, ProjectType } from '~/domain/models/project/project'
import { ProjectReadItem, ProjectType, ProjectItemList } from '~/domain/models/project/project'
export class ProjectDTO { export class ProjectDTO {
id: number id: number
@ -45,3 +45,17 @@ export class ProjectDTO {
} }
export type ProjectWriteDTO = Pick<ProjectDTO, 'id' | 'name' | 'description' | 'guideline' | 'projectType' | 'enableRandomOrder' | 'enableShareAnnotation' | 'singleClassClassification' | 'allowOverlapping' | 'graphemeMode' | 'tags'> export type ProjectWriteDTO = Pick<ProjectDTO, 'id' | 'name' | 'description' | 'guideline' | 'projectType' | 'enableRandomOrder' | 'enableShareAnnotation' | 'singleClassClassification' | 'allowOverlapping' | 'graphemeMode' | 'tags'>
export class ProjectListDTO {
count: number
next : string | null
prev : string | null
items: ProjectDTO[]
constructor(item: ProjectItemList) {
this.count = item.count
this.next = item.next
this.prev = item.prev
this.items = item.items.map(_ => new ProjectDTO(_))
}
}
Loading…
Cancel
Save