Browse Source

Merge pull request #1680 from doccano/enhancement/labelCreationProcess

[Enhancement] Label creation process
pull/1681/head
Hiroki Nakayama 2 years ago
committed by GitHub
parent
commit
e975241944
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 390 additions and 128 deletions
  1. 234
      frontend/components/label/FormCreate.vue
  2. 2
      frontend/domain/models/label/labelRepository.ts
  3. 88
      frontend/pages/projects/_id/labels/_label_id/edit.vue
  4. 105
      frontend/pages/projects/_id/labels/add.vue
  5. 78
      frontend/pages/projects/_id/labels/index.vue
  6. 6
      frontend/repositories/label/apiLabelRepository.ts
  7. 5
      frontend/services/application/label/labelApplicationService.ts

234
frontend/components/label/FormCreate.vue

@ -1,108 +1,204 @@
<template>
<base-card
:disabled="!valid"
:title="$t('labels.createLabel')"
:agree-text="$t('generic.save')"
:cancel-text="$t('generic.cancel')"
@agree="$emit('save')"
@cancel="$emit('cancel')"
>
<template #content>
<v-form v-model="valid">
<v-text-field
:value="text"
:label="$t('labels.labelName')"
:rules="[rules.required, rules.counter, rules.nameDuplicated]"
:prepend-icon="mdiLabel"
single-line
counter
autofocus
@input="updateValue('text', $event)"
/>
<v-select
:value="suffixKey"
:items="shortkeys"
:label="$t('labels.key')"
:rules="[rules.keyDuplicated]"
:prepend-icon="mdiKeyboard"
@input="updateValue('suffixKey', $event)"
/>
<v-color-picker
:value="backgroundColor"
:rules="[rules.required]"
show-swatches
hide-mode-switch
width="800"
@input="updateValue('backgroundColor', $event)"
/>
<v-card>
<v-card-title>Create a Label Type</v-card-title>
<v-card-text>
<v-form
ref="form"
v-model="valid"
>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
:value="text"
:counter="100"
:label="$t('labels.labelName')"
:rules="[rules.required, rules.counter, rules.nameDuplicated]"
outlined
required
@input="$emit('update:text', $event)"
/>
</v-col>
<v-col cols="12" sm="6">
<v-select
:value="suffixKey"
:items="availableSuffixKeys"
:label="$t('labels.key')"
:rules="[rules.keyDuplicated]"
outlined
@input="$emit('update:suffixKey', $event)"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="12">
<v-text-field
:value="backgroundColor"
:rules="[rules.validColor]"
label="Color"
hide-details="auto"
outlined
required
@input="$emit('update:backgroundColor', $event)"
/>
<v-chip-group
v-model="selectedColorIndex"
column
mandatory
>
<v-chip
v-for="color in predefinedColors"
:key="color"
:color="color"
filter
label
style="height: 32px; width: 32px;"
/>
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<v-chip
label
v-bind="attrs"
v-on="on"
@click="setRandomColor"
>
<v-icon>{{ mdiReload }}</v-icon>
</v-chip>
</template>
<span>Random color</span>
</v-tooltip>
</v-chip-group>
</v-col>
</v-row>
<v-row>
<v-col>
<div class="title black--text mb-2">Preview</div>
<v-chip
:color="backgroundColor"
:text-color="textColor"
>
{{ text }}
<v-avatar
v-if="suffixKey"
right
color="white"
class="black--text font-weight-bold"
>
{{ suffixKey }}
</v-avatar>
</v-chip>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="12">
<slot :valid="valid" />
</v-col>
</v-row>
</v-form>
</template>
</base-card>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import { mdiLabel, mdiKeyboard } from '@mdi/js'
import BaseCard from '@/components/utils/BaseCard.vue'
import Vue, { PropType } from 'vue'
import { mdiReload } from '@mdi/js';
import { LabelDTO } from '~/services/application/label/labelData'
export default Vue.extend({
components: {
BaseCard
},
props: {
text: {
type: String,
default: '',
items: {
type: Array as PropType<LabelDTO[]>,
default: () => [],
required: true
},
suffixKey: {
id: {
type: Number as () => number | undefined,
default: undefined
},
text: {
type: String,
default: null,
required: true,
},
backgroundColor: {
type: String,
default: '#ffffff',
required: true
},
usedNames: {
type: Array,
default: () => [],
required: true
required: true,
},
usedKeys: {
type: Array,
default: () => [],
required: true
suffixKey: {
type: String as () => string | null,
default: null
}
},
data() {
return {
selectedColorIndex: 0,
valid: false,
rules: {
required: (v: string) => !!v || 'Required',
// @ts-ignore
counter: (v: string) => (v && v.length <= 100) || this.$t('rules.labelNameRules').labelLessThan100Chars,
// @ts-ignore
nameDuplicated: (v: string) => (!this.usedNames.includes(v)) || this.$t('rules.labelNameRules').duplicated,
nameDuplicated: (v: string) => (!this.isUsedName(v)) || this.$t('rules.labelNameRules').duplicated,
// @ts-ignore
keyDuplicated: (v: string) => !this.usedKeys.includes(v) || this.$t('rules.keyNameRules').duplicated,
keyDuplicated: (v: string) => (!this.isUsedSuffixKey(v)) || this.$t('rules.keyNameRules').duplicated,
validColor: (v: string) => (/^#[0-9A-F]{6}$/i.test(v)) || 'This string is NOT a valid hex color.'
},
mdiLabel,
mdiKeyboard
mdiReload
}
},
computed: {
shortkeys() {
return '0123456789abcdefghijklmnopqrstuvwxyz'.split('')
availableSuffixKeys(): string[] {
const usedSuffixKeys = this.items.map(item => item.suffixKey).filter(item => item !== this.suffixKey)
const allSuffixKeys = '0123456789abcdefghijklmnopqrstuvwxyz'.split('')
return allSuffixKeys.filter(item => !usedSuffixKeys.includes(item))
},
predefinedColors(): string[] {
return [
'#73D8FF', '#009CE0', '#0062B1',
'#AEA1FF', '#7B64FF', '#653294',
'#FDA1FF', '#FA28FF', '#AB149E',
'#68CCCA', '#16A5A5', '#0C797D',
'#A4DD00', '#68BC00', '#194D33',
'#FCDC00', '#FCC400', '#FB9E00',
'#F44E3B', '#D33115', '#9F0500'
]
},
textColor(): string {
return this.$contrastColor(this.backgroundColor)
}
},
watch: {
selectedColorIndex(value) {
if (value < this.predefinedColors.length) {
this.$emit('update:backgroundColor', this.predefinedColors[this.selectedColorIndex])
}
}
},
methods: {
updateValue(key: string, value: string) {
this.$emit(`update:${key}`, value);
isUsedName(text: string): boolean {
return this.items.filter(item => item.id !== this.id && item.text === text).length > 0
},
isUsedSuffixKey(key: string) {
if (key === null) {
return false
}
return this.items.filter(item => item.id !== this.id && item.suffixKey === key).length > 0
},
setRandomColor() {
const maxVal = 0xFFFFFF
const randomNumber = Math.floor(Math.random() * maxVal)
const randomString = randomNumber.toString(16)
const randColor = randomString.padStart(6, '0')
this.$emit('update:backgroundColor', `#${randColor.toUpperCase()}`)
}
}
})

2
frontend/domain/models/label/labelRepository.ts

@ -3,6 +3,8 @@ import { LabelItem } from '~/domain/models/label/label'
export interface LabelRepository {
list(projectId: string): Promise<LabelItem[]>
findById(projectId: string, labelId: number): Promise<LabelItem>
create(projectId: string, item: LabelItem): Promise<LabelItem>
update(projectId: string, item: LabelItem): Promise<LabelItem>

88
frontend/pages/projects/_id/labels/_label_id/edit.vue

@ -0,0 +1,88 @@
<template>
<form-create
v-slot="slotProps"
v-bind.sync="editedItem"
:items="items"
>
<v-btn
:disabled="!slotProps.valid"
color="primary"
class="text-capitalize"
@click="save"
>
Save
</v-btn>
</form-create>
</template>
<script lang="ts">
import Vue from 'vue'
import { LabelDTO } from '~/services/application/label/labelData'
import { ProjectDTO } from '~/services/application/project/projectData'
import FormCreate from '~/components/label/FormCreate.vue'
export default Vue.extend({
components: {
FormCreate,
},
layout: 'project',
validate({ params, query, app }) {
if (!['category', 'span'].includes((query.type as string))) {
return false
}
if (/^\d+$/.test(params.id)) {
return app.$services.project.findById(params.id)
.then((res:ProjectDTO) => {
return res.canDefineLabel
})
}
return false
},
data() {
return {
editedItem: {
text: '',
prefixKey: null,
suffixKey: null,
backgroundColor: '#73D8FF',
textColor: '#ffffff'
} as LabelDTO,
items: [] as LabelDTO[]
}
},
computed: {
projectId(): string {
return this.$route.params.id
},
labelId(): string {
return this.$route.params.label_id
},
service(): any {
const type = this.$route.query.type
if (type === 'category') {
return this.$services.categoryType
} else {
return this.$services.spanType
}
},
},
async created() {
this.items = await this.service.list(this.projectId)
this.editedItem = await this.service.findById(this.projectId, this.labelId)
},
methods: {
async save() {
await this.service.update(this.projectId, this.editedItem)
this.$router.push(`/projects/${this.projectId}/labels`)
}
}
})
</script>

105
frontend/pages/projects/_id/labels/add.vue

@ -0,0 +1,105 @@
<template>
<form-create
v-slot="slotProps"
v-bind.sync="editedItem"
:items="items"
>
<v-btn
:disabled="!slotProps.valid"
color="primary"
class="text-capitalize"
@click="save"
>
Save
</v-btn>
<v-btn
:disabled="!slotProps.valid"
color="primary"
style="text-transform: none"
outlined
@click="saveAndAnother"
>
Save and add another
</v-btn>
</form-create>
</template>
<script lang="ts">
import Vue from 'vue'
import { LabelDTO } from '~/services/application/label/labelData'
import { ProjectDTO } from '~/services/application/project/projectData'
import FormCreate from '~/components/label/FormCreate.vue'
export default Vue.extend({
components: {
FormCreate,
},
layout: 'project',
validate({ params, query, app }) {
if (!['category', 'span'].includes((query.type as string))) {
return false
}
if (/^\d+$/.test(params.id)) {
return app.$services.project.findById(params.id)
.then((res:ProjectDTO) => {
return res.canDefineLabel
})
}
return false
},
data() {
return {
editedItem: {
text: '',
prefixKey: null,
suffixKey: null,
backgroundColor: '#73D8FF',
textColor: '#ffffff'
} as LabelDTO,
defaultItem: {
text: '',
prefixKey: null,
suffixKey: null,
backgroundColor: '#73D8FF',
textColor: '#ffffff'
} as LabelDTO,
items: [] as LabelDTO[]
}
},
computed: {
projectId(): string {
return this.$route.params.id
},
service(): any {
const type = this.$route.query.type
if (type === 'category') {
return this.$services.categoryType
} else {
return this.$services.spanType
}
},
},
async created() {
this.items = await this.service.list(this.projectId)
},
methods: {
async save() {
await this.service.create(this.projectId, this.editedItem)
this.$router.push(`/projects/${this.projectId}/labels`)
},
async saveAndAnother() {
await this.service.create(this.projectId, this.editedItem)
this.editedItem = Object.assign({}, this.defaultItem)
}
}
})
</script>

78
frontend/pages/projects/_id/labels/index.vue

@ -6,7 +6,7 @@
</v-tabs>
<v-card-title>
<action-menu
@create="dialogCreate=true"
@create="$router.push('labels/add?type=' + labelType)"
@upload="dialogUpload=true"
@download="download"
/>
@ -18,15 +18,6 @@
>
{{ $t('generic.delete') }}
</v-btn>
<v-dialog v-model="dialogCreate">
<form-create
v-bind.sync="editedItem"
:used-keys="usedKeys"
:used-names="usedNames"
@cancel="close"
@save="save"
/>
</v-dialog>
<v-dialog v-model="dialogUpload">
<form-upload
:error-message="errorMessage"
@ -55,7 +46,6 @@
<script lang="ts">
import Vue from 'vue'
import ActionMenu from '@/components/label/ActionMenu.vue'
import FormCreate from '@/components/label/FormCreate.vue'
import FormDelete from '@/components/label/FormDelete.vue'
import FormUpload from '@/components/label/FormUpload.vue'
import LabelList from '@/components/label/LabelList.vue'
@ -66,7 +56,6 @@ export default Vue.extend({
components: {
ActionMenu,
FormCreate,
FormDelete,
FormUpload,
LabelList
@ -85,24 +74,8 @@ export default Vue.extend({
data() {
return {
dialogCreate: false,
dialogDelete: false,
dialogUpload: false,
editedIndex: -1,
editedItem: {
text: '',
prefixKey: null,
suffixKey: null,
backgroundColor: '#2196F3',
textColor: '#ffffff'
} as LabelDTO,
defaultItem: {
text: '',
prefixKey: null,
suffixKey: null,
backgroundColor: '#2196F3',
textColor: '#ffffff'
} as LabelDTO,
items: [] as LabelDTO[],
selected: [] as LabelDTO[],
isLoading: false,
@ -116,18 +89,11 @@ export default Vue.extend({
canDelete(): boolean {
return this.selected.length > 0
},
projectId(): string {
return this.$route.params.id
},
usedNames(): string[] {
const item = this.items[this.editedIndex] // to remove myself
return this.items.filter(_ => _ !== item).map(item => item.text)
},
usedKeys(): string[] {
const item = this.items[this.editedIndex] // to remove myself
return this.items.filter(_ => _ !== item).map(item => item.suffixKey)
.filter(item => item !==null) as string[]
},
hasMultiType(): boolean {
if ('projectType' in this.project) {
return this.project.projectType === 'IntentDetectionAndSlotFilling'
@ -136,6 +102,20 @@ export default Vue.extend({
}
},
labelType(): string {
if (this.hasMultiType) {
if (this.tab === 0) {
return 'category'
} else {
return 'span'
}
} else if (this.project.projectType.endsWith('Classification')) {
return 'category'
} else {
return 'span'
}
},
service(): any {
if (!('projectType' in this.project)) {
return
@ -171,25 +151,7 @@ export default Vue.extend({
this.items = await this.service.list(this.projectId)
this.isLoading = false
},
async save() {
if (this.editedIndex > -1) {
await this.service.update(this.projectId, this.editedItem)
} else {
await this.service.create(this.projectId, this.editedItem)
}
await this.list()
this.close()
},
close() {
this.dialogCreate = false
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem)
this.editedIndex = -1
})
},
async remove() {
await this.service.bulkDelete(this.projectId, this.selected)
this.list()
@ -221,9 +183,7 @@ export default Vue.extend({
},
editItem(item: LabelDTO) {
this.editedIndex = this.items.indexOf(item)
this.editedItem = Object.assign({}, item)
this.dialogCreate = true
this.$router.push(`labels/${item.id}/edit?type=${this.labelType}`)
}
}
})

6
frontend/repositories/label/apiLabelRepository.ts

@ -24,6 +24,12 @@ export class APILabelRepository implements LabelRepository {
return response.data.map((item: any) => plainToInstance(LabelItem, item))
}
async findById(projectId: string, labelId: number): Promise<LabelItem> {
const url = `/projects/${projectId}/${this.baseUrl}s/${labelId}`
const response = await this.request.get(url)
return plainToInstance(LabelItem, response.data)
}
async create(projectId: string, item: LabelItem): Promise<LabelItem> {
const url = `/projects/${projectId}/${this.baseUrl}s`
const response = await this.request.post(url, item.toObject())

5
frontend/services/application/label/labelApplicationService.ts

@ -14,6 +14,11 @@ export class LabelApplicationService {
return items.map(item => new LabelDTO(item))
}
public async findById(projectId: string, labelId: number): Promise<LabelDTO> {
const item = await this.repository.findById(projectId, labelId)
return new LabelDTO(item)
}
public async create(projectId: string, item: CreateLabelCommand): Promise<LabelDTO> {
// Todo: use auto mapping.
const label = new LabelItem()

Loading…
Cancel
Save