history component
Gitea Actions Demo / Deploy (push) Successful in 36s

This commit is contained in:
vadim committed 2026-10-11 21:50:01 +03:00
1 parent 564ece4ff3
commit c0e815ae63
3 files changed
+292 -4

No files matched your search

+33
View File
@@ -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`)
}
+253 -3
View File
@@ -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>
+6 -1
View File
@@ -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>