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>
|
<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>
|
</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">
|
<div class="activity__window">
|
||||||
<comments-list v-if="activity === 'comment'" :issue-id="issue.id" class="h-100" />
|
<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>
|
</div>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</expansion-panel>
|
</expansion-panel>
|
||||||
|
|||||||
Reference in new issue
Block a user