feat(views): crud in frontend
This commit is contained in:
parent
433584813a
commit
434b1ea0e8
@ -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 } }"
|
||||
|
177
frontend/src/components/project/views/viewEditForm.vue
Normal file
177
frontend/src/components/project/views/viewEditForm.vue
Normal 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>
|
@ -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",
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
30
frontend/src/models/projectView.ts
Normal file
30
frontend/src/models/projectView.ts
Normal 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 = []
|
||||
}
|
||||
}
|
||||
}
|
@ -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',
|
||||
|
20
frontend/src/services/projectViews.ts
Normal file
20
frontend/src/services/projectViews.ts
Normal 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)
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
173
frontend/src/views/project/settings/views.vue
Normal file
173
frontend/src/views/project/settings/views.vue
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user