1 parent
564ece4ff3
commit
c0e815ae63
3 files changed
+292
-4
No files matched your search
@@ -0,0 +1,33 @@
|
||||
import { apiClient } from '@/lib/api-client.ts'
|
||||
import type { User } from '@/api/user.api.ts'
|
||||
|
||||
/** created — a pseudo-field: the issue creation record, both values are null */
|
||||
export type IssueChangeField = 'created' | 'title' | 'content' | 'status' | 'priority' | 'assignee' | 'dueDate'
|
||||
|
||||
/** Values are serialized: priority — number, dueDate — ISO date, assignee — user id */
|
||||
export interface IssueFieldChange {
|
||||
field: IssueChangeField
|
||||
oldValue: string | null
|
||||
newValue: string | null
|
||||
/** Only for field = assignee: null — unassigned or the user was deleted */
|
||||
oldUser?: User | null
|
||||
newUser?: User | null
|
||||
}
|
||||
|
||||
/** One save of the issue: who, when and which fields changed */
|
||||
export interface IssueHistoryEntry {
|
||||
/** changeId */
|
||||
id: string
|
||||
changedAt: string
|
||||
actorId: string | null
|
||||
/** null — system change or the actor deleted their account */
|
||||
actor: User | null
|
||||
changes: IssueFieldChange[]
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
/** Oldest first */
|
||||
export async function itemsList(issueId: number) {
|
||||
return apiClient.get<IssueHistoryEntry[]>(`/api/issues/${issueId}/history`)
|
||||
}
|
||||
@@ -1,7 +1,257 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { itemsList, type IssueFieldChange, type IssueHistoryEntry } from '@/api/history.api.ts'
|
||||
import type { User } from '@/api/user.api.ts'
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
|
||||
interface Props {
|
||||
issueId: number
|
||||
/** Changes when the issue is saved — the history is reloaded */
|
||||
version?: string
|
||||
}
|
||||
|
||||
const { issueId, version } = defineProps<Props>()
|
||||
|
||||
const FIELD_LABELS: Record<IssueFieldChange['field'], string> = {
|
||||
created: 'Created',
|
||||
title: 'Title',
|
||||
content: 'Details',
|
||||
status: 'Status',
|
||||
priority: 'Priority',
|
||||
assignee: 'Assignee',
|
||||
dueDate: 'Due Date',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
'0': 'Lowest',
|
||||
'1': 'Low',
|
||||
'2': 'Normal',
|
||||
'3': 'High',
|
||||
'4': 'Highest',
|
||||
}
|
||||
|
||||
const statusStore = useStatusStore()
|
||||
|
||||
const entries = ref<IssueHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
/** Change keys (entryId:field) with an expanded details diff */
|
||||
const expanded = reactive(new Set<string>())
|
||||
|
||||
async function load(id: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await itemsList(id)
|
||||
// The issue may have changed while the request was in flight
|
||||
if (id !== issueId) return
|
||||
entries.value = res
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get issue history'
|
||||
} finally {
|
||||
if (id === issueId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => issueId,
|
||||
(id) => {
|
||||
entries.value = []
|
||||
expanded.clear()
|
||||
load(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => version,
|
||||
() => load(issueId),
|
||||
)
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
/** Newest first */
|
||||
const items = computed(() =>
|
||||
[...entries.value].reverse().map((entry) => ({
|
||||
entry,
|
||||
time: dateTimeFormat.format(new Date(entry.changedAt)),
|
||||
created: entry.changes.some((c) => c.field === 'created'),
|
||||
changes: entry.changes.filter((c) => c.field !== 'created'),
|
||||
})),
|
||||
)
|
||||
|
||||
function userName(user: User | null | undefined) {
|
||||
return user ? user.name || user.email : 'Deleted user'
|
||||
}
|
||||
|
||||
function initials(user: User | null) {
|
||||
return user ? (user.name || user.email || '?')[0]!.toUpperCase() : '?'
|
||||
}
|
||||
|
||||
function status(alias: string) {
|
||||
return statusStore.statuses?.find((el) => el.alias === alias)
|
||||
}
|
||||
|
||||
function formatValue(change: IssueFieldChange, value: string | null) {
|
||||
if (value === null || value === '') return 'None'
|
||||
switch (change.field) {
|
||||
case 'status':
|
||||
return status(value)?.name ?? value
|
||||
case 'priority':
|
||||
return PRIORITY_LABELS[value] ?? value
|
||||
case 'dueDate':
|
||||
return new Date(value).toLocaleDateString('ru-RU')
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function changeKey(entry: IssueHistoryEntry, change: IssueFieldChange) {
|
||||
return `${entry.id}:${change.field}`
|
||||
}
|
||||
|
||||
function toggle(key: string) {
|
||||
if (expanded.has(key)) expanded.delete(key)
|
||||
else expanded.add(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-sheet class="px-4 py-2"> History </v-sheet>
|
||||
<div class="history px-2 py-3">
|
||||
<div v-if="loading && entries.length === 0" class="history__placeholder">
|
||||
<v-progress-circular indeterminate size="24" width="2" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error && entries.length === 0" class="history__placeholder text-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="entries.length === 0" class="history__placeholder text-medium-emphasis">No history yet</div>
|
||||
|
||||
<div v-for="item in items" :key="item.entry.id" class="history__entry">
|
||||
<v-avatar
|
||||
:image="item.entry.actor?.image ?? undefined"
|
||||
:text="initials(item.entry.actor)"
|
||||
color="primary"
|
||||
rounded="circle"
|
||||
density="compact"
|
||||
border
|
||||
/>
|
||||
|
||||
<div class="history__body">
|
||||
<div class="d-flex flex-wrap align-baseline ga-2">
|
||||
<span class="font-weight-medium">{{ userName(item.entry.actor) }}</span>
|
||||
<span v-if="item.created" class="text-medium-emphasis">created the issue</span>
|
||||
<span v-else class="text-medium-emphasis">made changes</span>
|
||||
<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 class="text-medium-emphasis">{{ FIELD_LABELS[change.field] }}:</div>
|
||||
|
||||
<div v-if="change.field === 'content'">
|
||||
<a class="history__toggle text-primary" @click="toggle(changeKey(item.entry, change))">
|
||||
{{ expanded.has(changeKey(item.entry, change)) ? 'Hide changes' : 'Show changes' }}
|
||||
</a>
|
||||
<div v-if="expanded.has(changeKey(item.entry, change))" class="d-flex flex-column ga-1 mt-1">
|
||||
<v-sheet border class="history__text history__text--old px-2 py-1">
|
||||
{{ formatValue(change, change.oldValue) }}
|
||||
</v-sheet>
|
||||
<v-sheet border class="history__text px-2 py-1">{{ formatValue(change, change.newValue) }}</v-sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="change.field === 'assignee'" class="history__values">
|
||||
<span class="history__old">{{ change.oldValue ? userName(change.oldUser) : 'Unassigned' }}</span>
|
||||
<v-icon icon="mdi-arrow-right" size="small" />
|
||||
<span>{{ change.newValue ? userName(change.newUser) : 'Unassigned' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="change.field === 'status'" class="history__values">
|
||||
<v-chip
|
||||
v-if="change.oldValue"
|
||||
:color="status(change.oldValue)?.color"
|
||||
:text="formatValue(change, change.oldValue)"
|
||||
density="compact"
|
||||
variant="flat"
|
||||
label
|
||||
/>
|
||||
<span v-else class="history__old">None</span>
|
||||
<v-icon icon="mdi-arrow-right" size="small" />
|
||||
<v-chip
|
||||
:color="change.newValue ? status(change.newValue)?.color : undefined"
|
||||
:text="formatValue(change, change.newValue)"
|
||||
density="compact"
|
||||
variant="flat"
|
||||
label
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="history__values">
|
||||
<span class="history__old">{{ formatValue(change, change.oldValue) }}</span>
|
||||
<v-icon icon="mdi-arrow-right" size="small" />
|
||||
<span>{{ formatValue(change, change.newValue) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
&__placeholder {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&__entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__change {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 1fr;
|
||||
column-gap: 0.5rem;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
&__values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__old {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
|
||||
&--old {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -164,7 +164,12 @@ defineExpose({
|
||||
|
||||
<div class="activity__window">
|
||||
<comments-list v-if="activity === 'comment'" :issue-id="issue.id" class="h-100" />
|
||||
<history-list v-else-if="activity === 'history'" />
|
||||
<history-list
|
||||
v-else-if="activity === 'history'"
|
||||
:issue-id="issue.id"
|
||||
:version="issue.updatedAt"
|
||||
class="h-100"
|
||||
/>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</expansion-panel>
|
||||
|
||||
Reference in new issue
Block a user