1 parent
10ad1569fb
commit
c96ef32fe9
11 files changed
+235
-4
No files matched your search
@@ -21,6 +21,7 @@ const FIELD_LABELS: Record<IssueFieldChange['field'], string> = {
|
||||
assignee: 'Assignee',
|
||||
dueDate: 'Due Date',
|
||||
type: 'Type',
|
||||
tag: 'Tag',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
@@ -145,7 +146,7 @@ function toggle(key: string) {
|
||||
<span class="text-medium-emphasis text-body-small ms-auto">{{ item.time }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="change in item.changes" :key="change.field" class="history__change">
|
||||
<div v-for="(change, idx) in item.changes" :key="idx" class="history__change">
|
||||
<div class="text-medium-emphasis">{{ FIELD_LABELS[change.field] }}:</div>
|
||||
|
||||
<div v-if="change.field === 'content'">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useTemplateRef, watchEffect } from 'vue'
|
||||
import { useIssueStore } from '@/stores/issue.js'
|
||||
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||
import ExpansionPanel from '@/components/ExpansionPanel.vue'
|
||||
import IssueContent from '@/components/IssueContent.vue'
|
||||
import type { Issue } from '@/api/issue.api.ts'
|
||||
@@ -17,6 +18,7 @@ import FileUpload from '@/components/FileUpload.vue'
|
||||
import PriorityMenu from '@/components/PriorityMenu.vue'
|
||||
import IssueType from '@/components/IssueType.vue'
|
||||
import ObserversList from '@/components/ObserversList.vue'
|
||||
import TagsSelector from '@/components/TagsSelector.vue'
|
||||
|
||||
interface Props {
|
||||
ckey: string
|
||||
@@ -32,8 +34,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const issueStore = useIssueStore()
|
||||
const snackbarStore = useSnackbarStore()
|
||||
const issue = ref<Issue>()
|
||||
const activity = ref()
|
||||
const tagsSaving = ref(false)
|
||||
|
||||
const { ckey } = defineProps<Props>()
|
||||
|
||||
@@ -120,6 +124,19 @@ const onAssigneeChanged = async (assignee: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onTagsChanged = async (tags: string[]) => {
|
||||
if (!issue.value) return
|
||||
|
||||
tagsSaving.value = true
|
||||
try {
|
||||
const updated = await issueStore.update(issue.value.id, { tags })
|
||||
if (updated) issue.value = updated
|
||||
else snackbarStore.add({ title: 'Failed to update tags', text: issueStore.error ?? '', color: 'error' })
|
||||
} finally {
|
||||
tagsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
applyUpdate: (updated: Issue) => {
|
||||
if (issue.value && issue.value.id === updated.id) {
|
||||
@@ -166,6 +183,14 @@ defineExpose({
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="u-grid">
|
||||
<div class="text-medium-emphasis">Tags:</div>
|
||||
<tags-selector
|
||||
:value="issue.tags"
|
||||
:project-id="issue.projectId"
|
||||
:saving="tagsSaving"
|
||||
@changed="onTagsChanged"
|
||||
/>
|
||||
|
||||
<div class="text-medium-emphasis">Created At:</div>
|
||||
<div>{{ createdAtFormatted }}</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { IssueTag } from '@/api/issue.api.ts'
|
||||
|
||||
interface Props {
|
||||
tag?: Pick<IssueTag, 'name' | 'color'>
|
||||
name?: string
|
||||
}
|
||||
|
||||
const { tag, name } = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 'default' is the backend's "no color", it is not a theme color -->
|
||||
<v-chip
|
||||
:color="tag?.color === 'default' ? undefined : tag?.color"
|
||||
:text="tag?.name ?? name"
|
||||
density="compact"
|
||||
variant="flat"
|
||||
label
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import type { IssueTag } from '@/api/issue.api.ts'
|
||||
import { TAG_NAME_MAX_LENGTH, tagsList } from '@/api/tag.api.ts'
|
||||
import TagChip from '@/components/TagChip.vue'
|
||||
|
||||
interface Props {
|
||||
value: IssueTag[]
|
||||
projectId: string
|
||||
/** The list is sent as a whole, so a change made during a save would overwrite it */
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ changed: [names: string[]] }>()
|
||||
|
||||
const search = ref('')
|
||||
const options = ref<string[]>([])
|
||||
const selected = computed(() => props.value.map((tag) => tag.name))
|
||||
|
||||
let requestId = 0
|
||||
|
||||
async function load() {
|
||||
// Ignore responses that arrive after the search has changed again
|
||||
const id = ++requestId
|
||||
try {
|
||||
const tags = await tagsList(props.projectId, { q: search.value.trim() })
|
||||
if (id === requestId) options.value = tags.map((tag) => tag.name)
|
||||
} catch (e) {
|
||||
console.error('Failed to load tags', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Also after a save: the issue may have just created a tag or released one back to the list
|
||||
watchDebounced([search, () => props.projectId, () => props.value], load, { debounce: 200, immediate: true })
|
||||
|
||||
const tagOf = (name: string) => props.value.find((tag) => tag.name === name)
|
||||
|
||||
const onSelected = (names: string[]) => {
|
||||
if (!props.saving) emit('changed', names)
|
||||
}
|
||||
|
||||
// A name missing from the project's tags is created by the backend; names differ by case only — it's the same tag
|
||||
const onEnter = (e: KeyboardEvent) => {
|
||||
const name = search.value.trim()
|
||||
if (e.isComposing || !name || props.saving) return
|
||||
|
||||
search.value = ''
|
||||
if (!selected.value.some((el) => el.toLowerCase() === name.toLowerCase())) {
|
||||
emit('changed', [...selected.value, name])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-autocomplete
|
||||
v-model:search="search"
|
||||
:model-value="selected"
|
||||
:items="options"
|
||||
:loading="saving"
|
||||
:maxlength="TAG_NAME_MAX_LENGTH"
|
||||
no-data-text="Press Enter to create"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
hide-selected
|
||||
clear-on-select
|
||||
multiple
|
||||
closable-chips
|
||||
@keydown.enter="onEnter"
|
||||
@update:model-value="onSelected"
|
||||
>
|
||||
<template #chip="{ item, props: chipProps }">
|
||||
<tag-chip v-bind="chipProps" :tag="tagOf(item)" :name="item" />
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
Reference in new issue
Block a user