feat(views): crud in frontend

This commit is contained in:
kolaente 2024-03-18 13:56:44 +01:00
parent 433584813a
commit 434b1ea0e8
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
10 changed files with 476 additions and 12 deletions

View File

@ -47,6 +47,12 @@
>
{{ $t('menu.edit') }}
</DropdownItem>
<DropdownItem
:to="{ name: 'project.settings.views', params: { projectId: project.id } }"
icon="eye"
>
{{ $t('menu.views') }}
</DropdownItem>
<DropdownItem
v-if="backgroundsEnabled"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"

View File

@ -0,0 +1,177 @@
<script setup lang="ts">
import type {IProjectView} from '@/modelTypes/IProjectView'
import XButton from '@/components/input/button.vue'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import {ref} from 'vue'
const model = defineModel<IProjectView>()
const titleValid = ref(true)
function validateTitle() {
titleValid.value = model.value.title !== ''
}
</script>
<template>
<form>
<div class="field">
<label
class="label"
for="title"
>
{{ $t('project.views.title') }}
</label>
<div class="control">
<input
id="title"
v-model="model.title"
v-focus
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
@blur="validateTitle"
>
</div>
<p
v-if="!titleValid"
class="help is-danger"
>
{{ $t('project.views.titleRequired') }}
</p>
</div>
<div class="field">
<label
class="label"
for="kind"
>
{{ $t('project.views.kind') }}
</label>
<div class="control">
<div class="select">
<select
id="kind"
v-model="model.viewKind"
>
<option value="list">
{{ $t('project.list.title') }}
</option>
<option value="gantt">
{{ $t('project.gantt.title') }}
</option>
<option value="table">
{{ $t('project.table.title') }}
</option>
<option value="kanban">
{{ $t('project.kanban.title') }}
</option>
</select>
</div>
</div>
</div>
<FilterInput
v-model="model.filter"
:input-label="$t('project.views.filter')"
/>
<div
v-if="model.viewKind === 'kanban'"
class="field"
>
<label
class="label"
for="configMode"
>
{{ $t('project.views.bucketConfigMode') }}
</label>
<div class="control">
<div class="select">
<select
id="configMode"
v-model="model.bucketConfigurationMode"
>
<option value="manual">
{{ $t('project.views.bucketConfigManual') }}
</option>
<option value="filter">
{{ $t('project.views.filter') }}
</option>
</select>
</div>
</div>
</div>
<div
v-if="model.viewKind === 'kanban' && model.bucketConfigurationMode === 'filter'"
class="field"
>
<label class="label">
{{ $t('project.views.bucketConfig') }}
</label>
<div class="control">
<div
v-for="(b, index) in model.bucketConfiguration"
:key="'bucket_'+index"
class="filter-bucket"
>
<button
class="is-danger"
@click.prevent="() => model.bucketConfiguration.splice(index, 1)"
>
<icon icon="trash-alt"/>
</button>
<div class="filter-bucket-form">
<div class="field">
<label class="label" :for="'bucket_'+index+'_title'">
{{ $t('project.views.title') }}
</label>
<div class="control">
<input
:id="'bucket_'+index+'_title'"
v-model="model.bucketConfiguration[index].title"
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
>
</div>
</div>
<FilterInput
v-model="model.bucketConfiguration[index].filter"
:inputLabel="$t('project.views.filter')"
/>
</div>
</div>
<div class="is-flex is-justify-content-end">
<x-button
variant="secondary"
icon="plus"
@click="() => model.bucketConfiguration.push({title: '', filter: ''})"
>
{{ $t('project.kanban.addBucket') }}
</x-button>
</div>
</div>
</div>
</form>
</template>
<style scoped lang="scss">
.filter-bucket {
display: flex;
button {
background: transparent;
border: none;
color: var(--danger);
padding-right: .75rem;
cursor: pointer;
}
&-form {
margin-bottom: .5rem;
padding: .5rem;
border: 1px solid var(--grey-200);
border-radius: $radius;
width: 100%;
}
}
</style>

View File

@ -381,6 +381,22 @@
"secret": "Secret",
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
"secretDocs": "Check out the docs for more details about how to use secrets."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
@ -1049,7 +1065,8 @@
"newProject": "New project",
"createProject": "Create project",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantDeleteIsDefault": "You cannot delete this because it is your default project.",
"views": "Views"
},
"apiConfig": {
"url": "Vikunja URL",

View File

@ -1,24 +1,31 @@
import type {IAbstract} from './IAbstract'
import type {ITask} from './ITask'
import type {IUser} from './IUser'
import type {ISubscription} from './ISubscription'
import type {IProject} from '@/modelTypes/IProject'
export const PROJECT_VIEW_KINDS = ['list', 'gantt', 'table', 'kanban']
export type ProjectViewKind = typeof PROJECT_VIEW_KINDS[number]
export const PROJECT_VIEW_BUCKET_CONFIGURATION_MODES = ['none', 'manual', 'filter']
export type ProjectViewBucketConfigurationMode = typeof PROJECT_VIEW_BUCKET_CONFIGURATION_MODES[number]
export interface IProjectViewBucketConfiguration {
title: string
filter: string
}
export interface IProjectView extends IAbstract {
id: number
title: string
projectId: IProject['id']
viewKind: 'list' | 'gantt' | 'table' | 'kanban'
fitler: string
viewKind: ProjectViewKind
filter: string
position: number
bucketConfigurationMode: 'none' | 'manual' | 'filter'
bucketConfiguration: object
bucketConfigurationMode: ProjectViewBucketConfigurationMode
bucketConfiguration: IProjectViewBucketConfiguration[]
defaultBucketId: number
doneBucketId: number
created: Date
updated: Date
}

View File

@ -7,6 +7,7 @@ import type {IProject} from '@/modelTypes/IProject'
import type {IUser} from '@/modelTypes/IUser'
import type {ITask} from '@/modelTypes/ITask'
import type {ISubscription} from '@/modelTypes/ISubscription'
import ProjectViewModel from '@/models/projectView'
export default class ProjectModel extends AbstractModel<IProject> implements IProject {
id = 0
@ -25,6 +26,7 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
parentProjectId = 0
doneBucketId = 0
defaultBucketId = 0
views = []
created: Date = null
updated: Date = null
@ -48,6 +50,8 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
this.subscription = new SubscriptionModel(this.subscription)
}
this.views = this.views.map(v => new ProjectViewModel(v))
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}

View File

@ -0,0 +1,30 @@
import type {IProjectView, ProjectViewBucketConfigurationMode, ProjectViewKind} from '@/modelTypes/IProjectView'
import AbstractModel from '@/models/abstractModel'
export default class ProjectViewModel extends AbstractModel<IProjectView> implements IProjectView {
id = 0
title = ''
projectId = 0
viewKind: ProjectViewKind = 'list'
filter = ''
position = 0
bucketConfiguration = []
bucketConfigurationMode: ProjectViewBucketConfigurationMode = 'manual'
defaultBucketId = 0
doneBucketId = 0
created: Date = new Date()
updated: Date = new Date()
constructor(data: Partial<IProjectView>) {
super()
this.assignData(data)
if (!this.bucketConfiguration) {
this.bucketConfiguration = []
}
}
}

View File

@ -44,6 +44,7 @@ const ProjectSettingShare = () => import('@/views/project/settings/share.vue')
const ProjectSettingWebhooks = () => import('@/views/project/settings/webhooks.vue')
const ProjectSettingDelete = () => import('@/views/project/settings/delete.vue')
const ProjectSettingArchive = () => import('@/views/project/settings/archive.vue')
const ProjectSettingViews = () => import('@/views/project/settings/views.vue')
// Saved Filters
const FilterNew = () => import('@/views/filters/FilterNew.vue')
@ -306,6 +307,15 @@ const router = createRouter({
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/views',
name: 'project.settings.views',
component: ProjectSettingViews,
meta: {
showAsModal: true,
},
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/projects/:projectId/settings/edit',
name: 'filter.settings.edit',

View File

@ -0,0 +1,20 @@
import AbstractService from '@/services/abstractService'
import type {IAbstract} from '@/modelTypes/IAbstract'
import ProjectViewModel from '@/models/projectView'
import type {IProjectView} from '@/modelTypes/IProjectView'
export default class ProjectViewService extends AbstractService<IProjectView> {
constructor() {
super({
get: '/projects/{projectId}/views/{id}',
getAll: '/projects/{projectId}/views',
create: '/projects/{projectId}/views',
update: '/projects/{projectId}/views/{id}',
delete: '/projects/{projectId}/views/{id}',
})
}
modelFactory(data: Partial<IAbstract>): ProjectViewModel {
return new ProjectViewModel(data)
}
}

View File

@ -18,6 +18,7 @@ import ProjectModel from '@/models/project'
import {success} from '@/message'
import {useBaseStore} from '@/stores/base'
import {getSavedFilterIdFromProjectId} from '@/services/savedFilter'
import type {IProjectView} from '@/modelTypes/IProjectView'
const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
@ -210,7 +211,24 @@ export const useProjectStore = defineStore('project', () => {
project,
]
}
function setProjectView(view: IProjectView) {
const viewPos = projects.value[view.projectId].views.findIndex(v => v.id === view.id)
if (viewPos !== -1) {
projects.value[view.projectId].views[viewPos] = view
return
}
projects.value[view.projectId].views.push(view)
}
function removeProjectView(projectId: IProject['id'], viewId: IProjectView['id']) {
const viewPos = projects.value[projectId].views.findIndex(v => v.id === viewId)
if (viewPos !== -1) {
projects.value[projectId].views.splice(viewPos, 1)
}
}
return {
isLoading: readonly(isLoading),
projects: readonly(projects),
@ -235,6 +253,8 @@ export const useProjectStore = defineStore('project', () => {
updateProject,
deleteProject,
getAncestors,
setProjectView,
removeProjectView,
}
})

View File

@ -0,0 +1,173 @@
<script setup lang="ts">
import CreateEdit from '@/components/misc/create-edit.vue'
import {computed, ref} from 'vue'
import {useProjectStore} from '@/stores/projects'
import ProjectViewModel from '@/models/projectView'
import type {IProjectView} from '@/modelTypes/IProjectView'
import ViewEditForm from '@/components/project/views/viewEditForm.vue'
import ProjectViewService from '@/services/projectViews'
import XButton from '@/components/input/button.vue'
import {error, success} from '@/message'
import {useI18n} from 'vue-i18n'
const {
projectId,
} = defineProps<{
projectId: number
}>()
const projectStore = useProjectStore()
const {t} = useI18n()
const views = computed(() => projectStore.projects[projectId]?.views)
const showCreateForm = ref(false)
const projectViewService = ref(new ProjectViewService())
const newView = ref<IProjectView>(new ProjectViewModel({}))
const viewIdToDelete = ref<number | null>(null)
const showDeleteModal = ref(false)
const viewToEdit = ref<IProjectView | null>(null)
async function createView() {
if (!showCreateForm.value) {
showCreateForm.value = true
return
}
if (newView.value.title === '') {
return
}
try {
newView.value.projectId = projectId
const result: IProjectView = await projectViewService.value.create(newView.value)
success({message: t('project.views.createSuccess')})
showCreateForm.value = false
projectStore.setProjectView(result)
newView.value = new ProjectViewModel({})
} catch (e) {
error(e)
}
}
async function deleteView() {
if (!viewIdToDelete.value) {
return
}
await projectViewService.value.delete(new ProjectViewModel({
id: viewIdToDelete.value,
projectId,
}))
projectStore.removeProjectView(projectId, viewIdToDelete.value)
showDeleteModal.value = false
}
async function saveView() {
const result = await projectViewService.value.update(viewToEdit.value)
projectStore.setProjectView(result)
viewToEdit.value = null
}
</script>
<template>
<CreateEdit
:title="$t('project.views.header')"
:primary-label="$t('misc.save')"
>
<ViewEditForm
v-if="showCreateForm"
v-model="newView"
class="mb-4"
/>
<div class="is-flex is-justify-content-end">
<x-button
@click="createView"
:loading="projectViewService.loading"
>
{{ $t('project.views.create') }}
</x-button>
</div>
<table
v-if="views?.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
>
<thead>
<tr>
<th>{{ $t('project.views.title') }}</th>
<th>{{ $t('project.views.kind') }}</th>
<th class="has-text-right">{{ $t('project.views.actions') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="v in views"
:key="v.id"
>
<template v-if="viewToEdit !== null && viewToEdit.id === v.id">
<td colspan="3">
<ViewEditForm
v-model="viewToEdit"
class="mb-4"
/>
<div class="is-flex is-justify-content-end">
<x-button
variant="tertiary"
@click="viewToEdit = null"
class="mr-2"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
@click="saveView"
:loading="projectViewService.loading"
>
{{ $t('misc.save') }}
</x-button>
</div>
</td>
</template>
<template v-else>
<td>{{ v.title }}</td>
<td>{{ v.viewKind }}</td>
<td class="has-text-right">
<x-button
class="is-danger mr-2"
icon="trash-alt"
@click="() => {
viewIdToDelete = v.id
showDeleteModal = true
}"
/>
<x-button
icon="pen"
@click="viewToEdit = {...v}"
/>
</td>
</template>
</tr>
</tbody>
</table>
</CreateEdit>
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteView"
>
<template #header>
<span>{{ $t('project.views.delete') }}</span>
</template>
<template #text>
<p>{{ $t('project.views.deleteText') }}</p>
</template>
</modal>
</template>
<style scoped lang="scss">
</style>