Compare commits
1
Commits
10ad1569fb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c96ef32fe9 |
No files matched your search
@@ -2,9 +2,10 @@ import { apiClient } from '@/lib/api-client.ts'
|
|||||||
import type { User } from '@/api/user.api.ts'
|
import type { User } from '@/api/user.api.ts'
|
||||||
|
|
||||||
/** created — a pseudo-field: the issue creation record, both values are null */
|
/** created — a pseudo-field: the issue creation record, both values are null */
|
||||||
export type IssueChangeField = 'created' | 'title' | 'content' | 'status' | 'priority' | 'assignee' | 'dueDate' | 'type'
|
export type IssueChangeField =
|
||||||
|
'created' | 'title' | 'content' | 'status' | 'priority' | 'assignee' | 'dueDate' | 'type' | 'tag'
|
||||||
|
|
||||||
/** Values are serialized: priority — number, dueDate — ISO date, assignee — user id */
|
/** Values are serialized: priority — number, dueDate — ISO date, assignee — user id, tag — one change per tag name */
|
||||||
export interface IssueFieldChange {
|
export interface IssueFieldChange {
|
||||||
field: IssueChangeField
|
field: IssueChangeField
|
||||||
oldValue: string | null
|
oldValue: string | null
|
||||||
|
|||||||
@@ -2,9 +2,16 @@ import { apiClient, toQuery } from '@/lib/api-client.ts'
|
|||||||
import type { User } from '@/api/user.api.ts'
|
import type { User } from '@/api/user.api.ts'
|
||||||
import type { IssueType } from '@/api/issue-type.api.ts'
|
import type { IssueType } from '@/api/issue-type.api.ts'
|
||||||
|
|
||||||
|
export interface IssueTag {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Issue {
|
export interface Issue {
|
||||||
id: number
|
id: number
|
||||||
key: string
|
key: string
|
||||||
|
projectId: string
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
status: 'OPEN' | 'IN_PROGRESS' | 'DONE' | 'CANCELLED'
|
status: 'OPEN' | 'IN_PROGRESS' | 'DONE' | 'CANCELLED'
|
||||||
@@ -14,6 +21,7 @@ export interface Issue {
|
|||||||
reporterId: string
|
reporterId: string
|
||||||
assignee?: User
|
assignee?: User
|
||||||
reporter?: User
|
reporter?: User
|
||||||
|
tags: IssueTag[]
|
||||||
dueDate: string | null
|
dueDate: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@@ -35,6 +43,8 @@ export interface CreateIssueInput {
|
|||||||
priority?: Issue['priority']
|
priority?: Issue['priority']
|
||||||
assignee?: string
|
assignee?: string
|
||||||
dueDate?: string | null
|
dueDate?: string | null
|
||||||
|
/** Names, the full new list; names missing from the project's tags are created */
|
||||||
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateIssueInput = Partial<CreateIssueInput>
|
export type UpdateIssueInput = Partial<CreateIssueInput>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
||||||
|
|
||||||
|
export const TAG_NAME_MAX_LENGTH = 50
|
||||||
|
/** Upper bound of the backend's list limit */
|
||||||
|
export const TAGS_LIST_MAX_LIMIT = 500
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIENT REQUEST
|
||||||
|
|
||||||
|
/** Sorted by name; q — case-insensitive substring of the name */
|
||||||
|
export async function tagsList(projectId: string, params: { q?: string; limit?: number } = {}) {
|
||||||
|
return apiClient.get<Tag[]>(`/api/projects/${projectId}/tags${toQuery(params)}`)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ const FIELD_LABELS: Record<IssueFieldChange['field'], string> = {
|
|||||||
assignee: 'Assignee',
|
assignee: 'Assignee',
|
||||||
dueDate: 'Due Date',
|
dueDate: 'Due Date',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
|
tag: 'Tag',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<string, string> = {
|
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>
|
<span class="text-medium-emphasis text-body-small ms-auto">{{ item.time }}</span>
|
||||||
</div>
|
</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 class="text-medium-emphasis">{{ FIELD_LABELS[change.field] }}:</div>
|
||||||
|
|
||||||
<div v-if="change.field === 'content'">
|
<div v-if="change.field === 'content'">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useTemplateRef, watchEffect } from 'vue'
|
import { computed, ref, useTemplateRef, watchEffect } from 'vue'
|
||||||
import { useIssueStore } from '@/stores/issue.js'
|
import { useIssueStore } from '@/stores/issue.js'
|
||||||
|
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||||
import ExpansionPanel from '@/components/ExpansionPanel.vue'
|
import ExpansionPanel from '@/components/ExpansionPanel.vue'
|
||||||
import IssueContent from '@/components/IssueContent.vue'
|
import IssueContent from '@/components/IssueContent.vue'
|
||||||
import type { Issue } from '@/api/issue.api.ts'
|
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 PriorityMenu from '@/components/PriorityMenu.vue'
|
||||||
import IssueType from '@/components/IssueType.vue'
|
import IssueType from '@/components/IssueType.vue'
|
||||||
import ObserversList from '@/components/ObserversList.vue'
|
import ObserversList from '@/components/ObserversList.vue'
|
||||||
|
import TagsSelector from '@/components/TagsSelector.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ckey: string
|
ckey: string
|
||||||
@@ -32,8 +34,10 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const issueStore = useIssueStore()
|
const issueStore = useIssueStore()
|
||||||
|
const snackbarStore = useSnackbarStore()
|
||||||
const issue = ref<Issue>()
|
const issue = ref<Issue>()
|
||||||
const activity = ref()
|
const activity = ref()
|
||||||
|
const tagsSaving = ref(false)
|
||||||
|
|
||||||
const { ckey } = defineProps<Props>()
|
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({
|
defineExpose({
|
||||||
applyUpdate: (updated: Issue) => {
|
applyUpdate: (updated: Issue) => {
|
||||||
if (issue.value && issue.value.id === updated.id) {
|
if (issue.value && issue.value.id === updated.id) {
|
||||||
@@ -166,6 +183,14 @@ defineExpose({
|
|||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
<v-card-text class="u-grid">
|
<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 class="text-medium-emphasis">Created At:</div>
|
||||||
<div>{{ createdAtFormatted }}</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>
|
||||||
@@ -2,6 +2,7 @@ import type { RouteRecordRaw } from 'vue-router'
|
|||||||
import ProjectsListView from '@/views/project/ProjectsListView.vue'
|
import ProjectsListView from '@/views/project/ProjectsListView.vue'
|
||||||
import SettingsView from '@/views/settings/SettingsView.vue'
|
import SettingsView from '@/views/settings/SettingsView.vue'
|
||||||
import GeneralSettingsView from '@/views/settings/GeneralSettingsView.vue'
|
import GeneralSettingsView from '@/views/settings/GeneralSettingsView.vue'
|
||||||
|
import TagsSettingsView from '@/views/settings/TagsSettingsView.vue'
|
||||||
|
|
||||||
export const settingsRoutes: RouteRecordRaw = {
|
export const settingsRoutes: RouteRecordRaw = {
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -21,5 +22,11 @@ export const settingsRoutes: RouteRecordRaw = {
|
|||||||
component: ProjectsListView,
|
component: ProjectsListView,
|
||||||
meta: { breadcrumb: 'Projects' },
|
meta: { breadcrumb: 'Projects' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tags',
|
||||||
|
name: 'settings-tags',
|
||||||
|
component: TagsSettingsView,
|
||||||
|
meta: { breadcrumb: 'Tags' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
+1
-1
@@ -127,7 +127,7 @@ export const useIssueStore = defineStore('item', () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return { stats, dailyStats, list, calendar, getByKey, create, update, remove, getStat, getDailyStat, reset }
|
return { stats, dailyStats, error, list, calendar, getByKey, create, update, remove, getStat, getDailyStat, reset }
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Local YYYY-MM-DD `days` days before today */
|
/** Local YYYY-MM-DD `days` days before today */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const tab = ref(0)
|
|||||||
<v-tabs v-model="tab">
|
<v-tabs v-model="tab">
|
||||||
<v-tab value="common" to="/settings">General</v-tab>
|
<v-tab value="common" to="/settings">General</v-tab>
|
||||||
<v-tab value="projects" to="/settings/projects">Projects</v-tab>
|
<v-tab value="projects" to="/settings/projects">Projects</v-tab>
|
||||||
|
<v-tab value="tags" to="/settings/tags">Tags</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
<div class="pa-4">
|
<div class="pa-4">
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useProjectStore } from '@/stores/project.ts'
|
||||||
|
import { type Tag, TAGS_LIST_MAX_LIMIT, tagsList } from '@/api/tag.api.ts'
|
||||||
|
import TagChip from '@/components/TagChip.vue'
|
||||||
|
|
||||||
|
const { currentProjectId } = storeToRefs(useProjectStore())
|
||||||
|
|
||||||
|
const items = ref<Tag[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function load(projectId: string | null) {
|
||||||
|
items.value = []
|
||||||
|
error.value = null
|
||||||
|
if (!projectId) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await tagsList(projectId, { limit: TAGS_LIST_MAX_LIMIT })
|
||||||
|
// The project may have changed while the request was in flight
|
||||||
|
if (projectId === currentProjectId.value) items.value = res
|
||||||
|
} catch (e) {
|
||||||
|
if (projectId === currentProjectId.value) error.value = e instanceof Error ? e.message : 'error get list of tags'
|
||||||
|
} finally {
|
||||||
|
if (projectId === currentProjectId.value) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(currentProjectId, load, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<v-alert v-if="error" type="error" density="compact" variant="flat">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<v-data-table
|
||||||
|
density="compact"
|
||||||
|
hide-default-footer
|
||||||
|
:loading="loading"
|
||||||
|
:headers="[
|
||||||
|
{ key: 'name', title: 'Name', sortable: false },
|
||||||
|
{ key: 'createdAt', title: 'Created', sortable: false },
|
||||||
|
]"
|
||||||
|
:items="items"
|
||||||
|
:items-per-page="-1"
|
||||||
|
:no-data-text="currentProjectId ? 'No tags yet' : 'Select a project'"
|
||||||
|
item-value="id"
|
||||||
|
>
|
||||||
|
<template #[`item.name`]="{ item }">
|
||||||
|
<tag-chip :tag="item" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.createdAt`]="{ item }">
|
||||||
|
{{ new Date(item.createdAt).toLocaleDateString('ru-RU') }}
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
Reference in new issue
Block a user