feat(frontend): add soft-delete UI with Bin page and undo support
- Add deletedAt field to IProject interface and ProjectModel - Add restore() and getDeletedProjects() to ProjectService - Add restoreProject() and fetchDeletedProjects() to project store - Create ProjectsBin.vue page showing deleted projects with restore - Add Bin link to sidebar navigation - Update delete modal text to softer language (move to bin) - Add undo toast action after deleting a project - Add route for /projects/bin
This commit is contained in:
parent
86cabee5c6
commit
1dcd27e566
|
|
@ -71,6 +71,16 @@
|
|||
{{ $t('team.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink
|
||||
:to="{ name: 'projects.bin'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
<Icon icon="trash-alt" />
|
||||
</span>
|
||||
{{ $t('project.bin.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</menu>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
|
|
@ -322,12 +322,22 @@
|
|||
"delete": {
|
||||
"title": "Delete \"{project}\"",
|
||||
"header": "Delete this project",
|
||||
"text1": "Are you sure you want to delete this project and all of its contents?",
|
||||
"text2": "This includes all tasks and CANNOT BE UNDONE!",
|
||||
"success": "The project was successfully deleted.",
|
||||
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
|
||||
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
|
||||
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete."
|
||||
"text1": "Are you sure you want to move this project to the bin?",
|
||||
"text2": "It will be permanently deleted after 30 days.",
|
||||
"success": "The project was moved to the bin.",
|
||||
"tasksToDelete": "This will affect approx. {count} tasks.",
|
||||
"tasksAndChildProjectsToDelete": "This will affect approx. {tasks} tasks and {projects} projects.",
|
||||
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete.",
|
||||
"undoSuccess": "The project was restored.",
|
||||
"undo": "Undo"
|
||||
},
|
||||
"bin": {
|
||||
"title": "Bin",
|
||||
"empty": "No deleted projects.",
|
||||
"daysRemaining": "{days} days remaining",
|
||||
"deletedOn": "Deleted on {date}",
|
||||
"restore": "Restore",
|
||||
"restoreSuccess": "The project was restored successfully."
|
||||
},
|
||||
"duplicate": {
|
||||
"title": "Duplicate this project",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ export interface IProject extends IAbstract {
|
|||
backgroundBlurHash: string
|
||||
parentProjectId: number
|
||||
views: IProjectView[]
|
||||
|
||||
deletedAt: Date | null
|
||||
|
||||
created: Date
|
||||
updated: Date
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
|||
backgroundBlurHash = ''
|
||||
parentProjectId = 0
|
||||
views: IProjectView[] = []
|
||||
|
||||
deletedAt: Date | null = null
|
||||
|
||||
created: Date = null
|
||||
updated: Date = null
|
||||
|
||||
|
|
@ -51,6 +52,10 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
|||
|
||||
this.views = this.views.map(v => new ProjectViewModel(v))
|
||||
|
||||
if (this.deletedAt) {
|
||||
this.deletedAt = new Date(this.deletedAt)
|
||||
}
|
||||
|
||||
this.created = new Date(this.created)
|
||||
this.updated = new Date(this.updated)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,6 +212,11 @@ const router = createRouter({
|
|||
name: 'projects.index',
|
||||
component: () => import('@/views/project/ListProjects.vue'),
|
||||
},
|
||||
{
|
||||
path: '/projects/bin',
|
||||
name: 'projects.bin',
|
||||
component: () => import('@/views/project/ProjectsBin.vue'),
|
||||
},
|
||||
{
|
||||
path: '/projects/new',
|
||||
name: 'project.create',
|
||||
|
|
|
|||
|
|
@ -52,6 +52,28 @@ export default class ProjectService extends AbstractService<IProject> {
|
|||
return window.URL.createObjectURL(new Blob([response.data]))
|
||||
}
|
||||
|
||||
async restore(projectId: IProject['id']): Promise<IProject> {
|
||||
const cancel = this.setLoading()
|
||||
|
||||
try {
|
||||
const response = await this.http.post(`/projects/${projectId}/restore`)
|
||||
return this.modelFactory(response.data)
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
async getDeletedProjects(): Promise<IProject[]> {
|
||||
const cancel = this.setLoading()
|
||||
|
||||
try {
|
||||
const response = await this.http.get('/projects/deleted')
|
||||
return response.data.map((p: IProject) => this.modelFactory(p))
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
async removeBackground(project: IProject) {
|
||||
const cancel = this.setLoading()
|
||||
|
||||
|
|
|
|||
|
|
@ -257,6 +257,30 @@ export const useProjectStore = defineStore('project', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function restoreProject(projectId: IProject['id']) {
|
||||
const cancel = setModuleLoading(setIsLoading)
|
||||
const projectService = new ProjectService()
|
||||
|
||||
try {
|
||||
const restoredProject = await projectService.restore(projectId)
|
||||
setProject(restoredProject)
|
||||
return restoredProject
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDeletedProjects(): Promise<IProject[]> {
|
||||
const cancel = setModuleLoading(setIsLoading)
|
||||
const projectService = new ProjectService()
|
||||
|
||||
try {
|
||||
return await projectService.getDeletedProjects()
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllProjects() {
|
||||
const cancel = setModuleLoading(setIsLoading)
|
||||
|
||||
|
|
@ -353,6 +377,8 @@ export const useProjectStore = defineStore('project', () => {
|
|||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
restoreProject,
|
||||
fetchDeletedProjects,
|
||||
getAncestors,
|
||||
setProjectView,
|
||||
removeProjectView,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div
|
||||
class="content loader-container"
|
||||
:class="{'is-loading': isLoading}"
|
||||
>
|
||||
<h1>{{ $t('project.bin.title') }}</h1>
|
||||
|
||||
<p v-if="deletedProjects.length === 0 && !isLoading">
|
||||
{{ $t('project.bin.empty') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="project in deletedProjects"
|
||||
:key="project.id"
|
||||
class="deleted-project"
|
||||
>
|
||||
<div class="deleted-project-info">
|
||||
<span class="deleted-project-title">{{ project.title }}</span>
|
||||
<span class="deleted-project-meta">
|
||||
{{ $t('project.bin.deletedOn', {date: formatDateShort(project.deletedAt)}) }}
|
||||
—
|
||||
{{ $t('project.bin.daysRemaining', {days: daysRemaining(project.deletedAt)}) }}
|
||||
</span>
|
||||
</div>
|
||||
<XButton
|
||||
variant="secondary"
|
||||
:loading="restoring === project.id"
|
||||
@click="restoreProject(project)"
|
||||
>
|
||||
{{ $t('project.bin.restore') }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const SOFT_DELETE_RETENTION_DAYS = 30
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
useTitle(() => t('project.bin.title'))
|
||||
|
||||
const deletedProjects = ref<IProject[]>([])
|
||||
const isLoading = ref(false)
|
||||
const restoring = ref<number | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
deletedProjects.value = await projectStore.fetchDeletedProjects()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function daysRemaining(deletedAt: Date | null): number {
|
||||
if (!deletedAt) return 0
|
||||
const deleted = new Date(deletedAt)
|
||||
const purgeDate = new Date(deleted.getTime() + SOFT_DELETE_RETENTION_DAYS * 24 * 60 * 60 * 1000)
|
||||
const remaining = Math.ceil((purgeDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
|
||||
return Math.max(0, remaining)
|
||||
}
|
||||
|
||||
async function restoreProject(project: IProject) {
|
||||
restoring.value = project.id
|
||||
try {
|
||||
await projectStore.restoreProject(project.id)
|
||||
deletedProjects.value = deletedProjects.value.filter(p => p.id !== project.id)
|
||||
success({message: t('project.bin.restoreSuccess')})
|
||||
} finally {
|
||||
restoring.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.deleted-project {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-block-end: 1px solid var(--grey-200);
|
||||
|
||||
&:last-child {
|
||||
border-block-end: none;
|
||||
}
|
||||
}
|
||||
|
||||
.deleted-project-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.deleted-project-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.deleted-project-meta {
|
||||
font-size: 0.875rem;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
/>
|
||||
|
||||
<p>
|
||||
{{ $t('misc.cannotBeUndone') }}
|
||||
{{ $t('project.delete.text2') }}
|
||||
</p>
|
||||
</template>
|
||||
</Modal>
|
||||
|
|
@ -88,8 +88,17 @@ async function deleteProject() {
|
|||
return
|
||||
}
|
||||
|
||||
await projectStore.deleteProject(project.value)
|
||||
success({message: t('project.delete.success')})
|
||||
const deletedProject = project.value
|
||||
await projectStore.deleteProject(deletedProject)
|
||||
success({message: t('project.delete.success')}, [
|
||||
{
|
||||
title: t('project.delete.undo'),
|
||||
callback: async () => {
|
||||
await projectStore.restoreProject(deletedProject.id)
|
||||
success({message: t('project.delete.undoSuccess')})
|
||||
},
|
||||
},
|
||||
])
|
||||
router.push({name: 'home'})
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in New Issue