Merge branch 'master' into enh/archiveAllCardsFromStack

This commit is contained in:
Jakob
2020-08-17 21:03:21 +02:00
committed by Jakob Röhrl
260 changed files with 10928 additions and 6879 deletions

View File

@@ -154,6 +154,7 @@ export default {
.visualdiff ins {
color: green;
}
.visualdiff del {
color: darkred;
}

View File

@@ -207,8 +207,4 @@ export default {
margin: 20px 20px 60px 20px;
}
.modal__content button {
float: right;
margin: 40px 3px 3px 0;
}
</style>

View File

@@ -35,7 +35,7 @@ import { CollectionList } from 'nextcloud-vue-collections'
export default {
name: 'CollaborationView',
components: {
CollectionList: CollectionList,
CollectionList,
},
computed: {
boardId() {

View File

@@ -31,11 +31,13 @@
</p>
</div>
<div v-if="board" class="board-actions">
<div v-if="canManage && !showArchived"
<div v-if="canManage && !showArchived && !board.archived"
id="stack-add"
v-click-outside="hideAddStack">
<Actions v-if="!isAddStackVisible" :title="t('deck', 'Add new list')">
<ActionButton icon="icon-add" @click.stop="showAddStack" />
<Actions v-if="!isAddStackVisible">
<ActionButton icon="icon-add" @click.stop="showAddStack">
{{ t('deck', 'Add new list') }}
</ActionButton>
</Actions>
<form v-else @submit.prevent="addNewStack()">
<label for="new-stack-input-main" class="hidden-visually">{{ t('deck', 'Add new list') }}</label>
@@ -54,14 +56,14 @@
</div>
<div class="board-action-buttons">
<Popover>
<Actions slot="trigger" :style="filterOpacity" :title="t('deck', 'Apply filter')">
<ActionButton icon="icon-filter" />
<Actions slot="trigger" :title="t('deck', 'Apply filter')">
<ActionButton v-if="isFilterActive" icon="icon-filter_set" />
<ActionButton v-else icon="icon-filter" />
</Actions>
<template>
<div class="filter">
<h3>{{ t('deck', 'Filter by tag') }}</h3>
{{ filter }}
<div v-for="label in board.labels" :key="label.id" class="filter--item">
<input
:id="label.id"
@@ -74,6 +76,17 @@
</div>
<h3>{{ t('deck', 'Filter by assigned user') }}</h3>
<div class="filter--item">
<input
id="unassigned"
v-model="filter.unassigned"
type="checkbox"
class="checkbox"
value="unassigned"
@change="setFilter"
@click="beforeSetFilter">
<label for="unassigned">{{ t('deck', 'Unassigned') }}</label>
</div>
<div v-for="user in board.users" :key="user.uid" class="filter--item">
<input
:id="user.uid"
@@ -146,6 +159,10 @@
@click="beforeSetFilter">
<label for="noDue">{{ t('deck', 'No due date') }}</label>
</div>
<Button :disabled="!isFilterActive" @click="clearFilter">
{{ t('deck', 'Clear filter') }}
</Button>
</div>
</template>
</Popover>
@@ -196,7 +213,7 @@ export default {
stack: '',
showArchived: false,
isAddStackVisible: false,
filter: { tags: [], users: [], due: '' },
filter: { tags: [], users: [], due: '', unassigned: false },
}
},
@@ -219,11 +236,19 @@ export default {
}
return 'opacity: .5;'
},
filterOpacity() {
isFilterActive() {
if (this.filter.tags.length !== 0 || this.filter.users.length !== 0 || this.filter.due !== '') {
return 'opacity: 1;'
return true
}
return 'opacity: .5;'
return false
},
labelsSorted() {
return [...this.board.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
},
watch: {
board() {
this.clearFilter()
},
},
methods: {
@@ -232,8 +257,14 @@ export default {
this.filter.due = ''
this.$store.dispatch('setFilter', { ...this.filter })
}
if (e.target.value === 'unassigned') {
this.filter.users = []
}
},
setFilter() {
if (this.filter.users.length > 0) {
this.filter.unassigned = false
}
this.$nextTick(() => this.$store.dispatch('setFilter', { ...this.filter }))
},
toggleNav() {
@@ -266,12 +297,19 @@ export default {
this.$router.push({ name: 'board.details' })
}
},
clearFilter() {
const filterReset = { tags: [], users: [], due: '' }
this.$store.dispatch('setFilter', { ...filterReset })
this.filter = filterReset
},
},
}
</script>
<style lang="scss" scoped>
.controls {
display: flex;
.board-title {
display: flex;
align-items: center;
@@ -299,20 +337,12 @@ export default {
}
#app-navigation-toggle-custom {
position: static;
width: 44px;
height: 44px;
cursor: pointer;
opacity: 1;
display: inline-block !important;
position: fixed;
}
.controls {
display: flex;
}
#app-navigation-toggle-custom {
position: static;
}
.board-actions {
@@ -331,6 +361,7 @@ export default {
background-color: transparent;
}
}
.filter--item {
input + label {
display: block;
@@ -347,11 +378,13 @@ export default {
}
}
}
.filter {
width: 250px;
max-height: 80vh;
overflow: auto;
}
.filter h3 {
margin-top: 0px;
margin-bottom: 5px;

View File

@@ -1,35 +0,0 @@
<!--
- @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
-
- @author Julius Härtl <jus@bitgrid.net>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div />
</template>
<script>
export default {
name: 'List',
}
</script>
<style scoped>
</style>

View File

@@ -23,13 +23,33 @@
<template>
<div class="board-wrapper">
<Controls :board="board" />
<transition name="fade" mode="out-in">
<div v-if="loading" key="loading" class="emptycontent">
<div class="icon icon-loading" />
<h2>{{ t('deck', 'Loading board') }}</h2>
<p />
</div>
<div v-else-if="board && !loading" key="board" class="board">
<EmptyContent v-else-if="isEmpty" key="empty" icon="icon-deck">
{{ t('deck', 'No lists available') }}
<template #desc>
{{ t('deck', 'Create a new list to add cards to this board') }}
<form @submit.prevent="addNewStack()">
<input id="new-stack-input-main"
v-model="newStackTitle"
v-focus
type="text"
class="no-close"
:placeholder="t('deck', 'List name')"
required>
<input v-tooltip="t('deck', 'Add new list')"
class="icon-confirm"
type="submit"
value="">
</form>
</template>
</EmptyContent>
<div v-else-if="!isEmpty && !loading" key="board" class="board">
<Container lock-axix="y"
orientation="horizontal"
:drag-handle-selector="dragHandleSelector"
@@ -54,6 +74,7 @@ import { Container, Draggable } from 'vue-smooth-dnd'
import { mapState, mapGetters } from 'vuex'
import Controls from '../Controls'
import Stack from './Stack'
import { EmptyContent } from '@nextcloud/vue'
export default {
name: 'Board',
@@ -62,6 +83,7 @@ export default {
Container,
Draggable,
Stack,
EmptyContent,
},
inject: [
'boardApi',
@@ -72,9 +94,10 @@ export default {
default: null,
},
},
data: function() {
data() {
return {
loading: true,
newStackTitle: '',
}
},
computed: {
@@ -91,6 +114,9 @@ export default {
dragHandleSelector() {
return this.canEdit ? null : '.no-drag'
},
isEmpty() {
return this.stacksByBoard.length === 0
},
},
watch: {
id: 'fetchData',
@@ -117,13 +143,13 @@ export default {
this.$store.dispatch('orderStack', { stack: this.stacksByBoard[removedIndex], removedIndex, addedIndex })
},
createStack() {
addNewStack() {
const newStack = {
title: 'FooBar',
title: this.newStackTitle,
boardId: this.id,
order: this.stacksByBoard().length,
}
this.$store.dispatch('createStack', newStack)
this.newStackTitle = ''
},
},
}
@@ -131,12 +157,25 @@ export default {
<style lang="scss" scoped>
@import "../../css/animations.scss";
@import '../../css/animations.scss';
$board-spacing: 15px;
$stack-spacing: 10px;
$stack-width: 300px;
form {
text-align: center;
display: flex;
width: 100%;
max-width: 200px;
margin: auto;
margin-top: 20px;
input[type=text] {
flex-grow: 1;
}
}
.board-wrapper {
position: relative;
width: 100%;

View File

@@ -89,6 +89,12 @@ export default {
li {
display: flex;
height: 44px;
&:hover, &:active, &.focus {
button {
opacity: 1;
}
}
}
span {
@@ -119,12 +125,5 @@ export default {
background-color: transparent;
opacity: 0.5;
}
li {
&:hover, &:active, &.focus {
button {
opacity: 1;
}
}
}
}
</style>

View File

@@ -178,20 +178,25 @@ export default {
#shareWithList {
margin-bottom: 20px;
}
#shareWithList li {
display: flex;
align-items: center;
}
.username {
padding: 12px 9px;
flex-grow: 1;
}
.board-owner-label {
opacity: .7;
}
.avatarLabel {
padding: 6px
}
.avatardiv {
background-color: #f5f5f5;
border-radius: 16px;

View File

@@ -25,21 +25,24 @@
<div class="stack">
<div v-click-outside="stopCardCreation" class="stack--header">
<transition name="fade" mode="out-in">
<h3 v-if="!canManage">
<h3 v-if="!canManage || isArchived">
{{ stack.title }}
</h3>
<h3 v-else-if="!editing" @click="startEditing(stack)">
<h3 v-else-if="!editing"
v-tooltip="stack.title"
class="stack--title"
@click="startEditing(stack)">
{{ stack.title }}
</h3>
<form v-else @submit.prevent="finishedEdit(stack)">
<input v-model="copiedStack.title" v-focus type="text">
<input v-tooltip="t('deck', 'Add a new stack')"
<input v-tooltip="t('deck', 'Add a new list')"
class="icon-confirm"
type="submit"
value="">
</form>
</transition>
<Actions v-if="canManage" :force-menu="true">
<Actions v-if="canManage && !isArchived" :force-menu="true">
<ActionButton icon="icon-archive" @click="modalArchivAllCardsShow=true">
{{ t('deck', 'Archive all cards') }}
</ActionButton>
@@ -47,7 +50,7 @@
{{ t('deck', 'Delete list') }}
</ActionButton>
</Actions>
<Actions v-if="canEdit && !showArchived">
<Actions v-if="canEdit && !showArchived && !isArchived">
<ActionButton icon="icon-add" @click.stop="showAddCard=true">
{{ t('deck', 'Add card') }}
</ActionButton>
@@ -57,7 +60,7 @@
<Modal v-if="modalArchivAllCardsShow" @close="modalArchivAllCardsShow=false">
<div class="modal__content">
<h3>{{ t('deck', 'Archive all cards in this list') }}</h3>
<progress :value="archiveAllCardsProgress" :max="stackLen" />
<progress :value="stackTransfer.current" :max="stackTransfer.total" />
<button class="primary" @click="archiveAllCardsFromStack(stack)">
{{ t('deck', 'Archive all cards') }}
</button>
@@ -112,7 +115,9 @@
import { mapGetters, mapState } from 'vuex'
import { Container, Draggable } from 'vue-smooth-dnd'
import { Actions, ActionButton, Modal } from '@nextcloud/vue'
import { showError } from '@nextcloud/dialogs'
import CardItem from '../cards/CardItem'
export default {
@@ -141,14 +146,17 @@ export default {
stateCardCreating: false,
animate: false,
modalArchivAllCardsShow: false,
archiveAllCardsProgress: null,
stackLen: 0,
stackTransfer: {
total: 0,
current: null,
},
}
},
computed: {
...mapGetters([
'canManage',
'canEdit',
'isArchived',
]),
...mapState({
showArchived: state => state.showArchived,
@@ -200,9 +208,9 @@ export default {
},
archiveAllCardsFromStack(stack) {
this.stackLen = this.cardsByStack.length
this.stackTransfer.total = this.cardsByStack.length
this.cardsByStack.forEach((card, index) => {
this.archiveAllCardsProgress = index
this.stackTransfer.current = index
this.$store.dispatch('archiveUnarchiveCard', { ...card, archived: true })
})
this.modalArchivAllCardsShow = false
@@ -234,7 +242,7 @@ export default {
})
this.$router.push({ name: 'card', params: { cardId: newCard.id } })
} catch (e) {
OCP.Toast.error('Could not create card: ' + e.response.data.message)
showError('Could not create card: ' + e.response.data.message)
} finally {
this.stateCardCreating = false
}
@@ -263,12 +271,14 @@ export default {
margin: 3px -3px;
margin-right: -10px;
margin-top: 0;
margin-bottom: 0;
margin-bottom: 3px;
background-color: var(--color-main-background-translucent);
cursor: grab;
h3, form {
flex-grow: 1;
display: flex;
cursor: inherit;
input[type=text] {
flex-grow: 1;
@@ -276,6 +286,13 @@ export default {
}
}
.stack--title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc($stack-width - 60px);
}
.stack--card-add {
position: sticky;
top: 52px;
@@ -310,6 +327,7 @@ export default {
border: none;
}
}
.stack .smooth-dnd-container.vertical {
margin-top: 3px;
}
@@ -322,6 +340,7 @@ export default {
.slide-top-leave-active {
transition: all 100ms ease;
}
.slide-top-enter, .slide-top-leave-to {
transform: translateY(-10px);
opacity: 0;
@@ -334,10 +353,6 @@ export default {
min-height: 100px;
text-align: center;
margin: 20px 20px 20px 20px;
.multiselect {
margin-bottom: 10px;
}
}
.modal__content button {

View File

@@ -1,7 +1,7 @@
<template>
<div>
<ul class="labels">
<li v-for="label in labels" :key="label.id" :class="{editing: (editingLabelId === label.id)}">
<li v-for="label in labelsSorted" :key="label.id" :class="{editing: (editingLabelId === label.id)}">
<!-- Edit Tag -->
<template v-if="editingLabelId === label.id">
<form class="label-form" @submit.prevent="updateLabel(label)">
@@ -14,24 +14,30 @@
type="submit"
value=""
class="icon-confirm">
<input v-tooltip="t('deck', 'Cancel')"
value=""
class="icon-close"
@click="editingLabelId = null">
<Actions>
<ActionButton v-tooltip="{content: missingDataLabel, show: !editLabelObjValidated, trigger: 'manual' }"
:disabled="!editLabelObjValidated"
icon="icon-close"
@click="editingLabelId = null">
{{ t('deck', 'Cancel') }}
</ActionButton>
</Actions>
</form>
</template>
<template v-else>
<div :style="{ backgroundColor: `#${label.color}`, color:textColor(label.color) }" class="label-title">
<span>{{ label.title }}</span>
<div class="label-title" @click="clickEdit(label)">
<span :style="{ backgroundColor: `#${label.color}`, color: textColor(label.color) }">{{ label.title }}</span>
</div>
<button v-if="canManage"
v-tooltip="t('deck', 'Edit')"
class="icon-rename"
@click="clickEdit(label)" />
<button v-if="canManage"
v-tooltip="t('deck', 'Delete')"
class="icon-delete"
@click="deleteLabel(label.id)" />
<Actions v-if="canManage && !isArchived">
<ActionButton icon="icon-rename" @click="clickEdit(label)">
{{ t('deck', 'Edit') }}
</ActionButton>
</Actions>
<Actions v-if="canManage && !isArchived">
<ActionButton icon="icon-delete" @click="deleteLabel(label.id)">
{{ t('deck', 'Delete') }}
</ActionButton>
</Actions>
</template>
</li>
@@ -48,14 +54,15 @@
type="submit"
value=""
class="icon-confirm">
<input v-tooltip="t('deck', 'Cancel')"
value=""
class="icon-close"
@click="addLabel=false">
<Actions>
<ActionButton icon="icon-close" @click="addLabel=false">
{{ t('deck', 'Cancel') }}
</ActionButton>
</Actions>
</form>
</template>
</li>
<button v-if="canManage" @click="clickShowAddLabel()">
<button v-if="canManage && !isArchived" @click="clickShowAddLabel()">
<span class="icon-add" />{{ t('deck', 'Add a new tag') }}
</button>
</ul>
@@ -66,12 +73,14 @@
import { mapGetters } from 'vuex'
import Color from '../../mixins/color'
import { ColorPicker } from '@nextcloud/vue'
import { ColorPicker, Actions, ActionButton } from '@nextcloud/vue'
export default {
name: 'TagsTabSidebar',
components: {
ColorPicker,
Actions,
ActionButton,
},
mixins: [Color],
data() {
@@ -88,6 +97,7 @@ export default {
...mapGetters({
labels: 'currentBoardLabels',
canManage: 'canManage',
isArchived: 'isArchived',
}),
addLabelObjValidated() {
if (this.addLabelObj.title === '') {
@@ -111,6 +121,9 @@ export default {
return true
},
labelsSorted() {
return [...this.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
},
methods: {
@@ -145,7 +158,7 @@ export default {
}
</script>
<style scoped lang="scss">
$clickable-area: 37px;
$clickable-area: 44px;
.labels li {
display: flex;
@@ -153,12 +166,23 @@ export default {
align-items: stretch;
height: $clickable-area;
&:hover {
background-color: var(--color-background-hover);
border-radius: 3px;
}
.label-title {
flex-grow: 1;
border-radius: 3px;
padding: 7px;
padding: 10px;
&:hover, span:hover {
cursor: pointer;
}
span {
vertical-align: middle;
border-radius: 15px;
padding: 7px 12px;
}
}
&:not(.editing) button {
@@ -191,15 +215,11 @@ export default {
display: flex;
input[type=text] {
flex-grow: 1;
margin: 5px;
}
input[type=submit] {
margin-top: 5px;
}
}
button,
input:not([type='text']):last-child {
min-width: $clickable-area;
border-radius: 0 var(--border-radius) var(--border-radius) 0;
margin-left: -1px;
width: 35px;
background-color: var(--color-main-background);
}
}
</style>

View File

@@ -60,7 +60,7 @@ export default {
},
},
computed: {
routeTo: function() {
routeTo() {
return {
name: 'board',
params: { id: this.board.id },

View File

@@ -29,14 +29,14 @@
<div class="board-list-bullet" />
</div>
<div class="board-list-title-cell">
Title
{{ t('deck', 'Title') }}
</div>
<div class="board-list-avatars-cell">
Members
{{ t('deck', 'Members') }}
</div>
<div class="board-list-actions-cell" />
</div>
<BoardItem v-for="board in filteredBoards" :key="board.id" :board="board" />
<BoardItem v-for="board in boardsSorted" :key="board.id" :board="board" />
</div>
</div>
</template>
@@ -59,15 +59,18 @@ export default {
},
},
computed: {
boardsSorted() {
return [...this.filteredBoards].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
filteredBoards() {
const query = this.$store.getters.getSearchQuery
return this.$store.getters.filteredBoards.filter((board) => {
return board.title.toLowerCase().includes(query.toLowerCase())
return board.deletedAt <= 0 && board.title.toLowerCase().includes(query.toLowerCase())
})
},
},
watch: {
navFilter: function(value) {
navFilter(value) {
this.$store.commit('setBoardFilter', value)
},
},
@@ -83,6 +86,11 @@ export default {
display: flex;
}
.board-list-row:not(.board-list-header-row):hover {
transition: background-color 0.3s ease;
background-color: var(--color-background-dark);
}
.board-list-header-row {
color: var(--color-text-lighter);
}

View File

@@ -21,8 +21,27 @@
-->
<template>
<div class="attachment-list">
<ul>
<AttachmentDragAndDrop :card-id="cardId" class="drop-upload--sidebar">
<button class="icon-upload" @click="clickAddNewAttachmment()">
{{ t('deck', 'Upload attachment') }}
</button>
<input ref="localAttachments"
type="file"
style="display: none;"
multiple
@change="handleUploadFile">
<ul class="attachment-list">
<li v-for="attachment in uploadQueue" :key="attachment.name" class="attachment">
<a class="fileicon" :style="mimetypeForAttachment('none')" />
<div class="details">
<a>
<div class="filename">
<span class="basename">{{ attachment.name }}</span>
</div>
<progress :value="attachment.progress" max="100" />
</a>
</div>
</li>
<li v-for="attachment in attachments"
:key="attachment.id"
class="attachment">
@@ -53,21 +72,29 @@
</Actions>
</li>
</ul>
</div>
</AttachmentDragAndDrop>
</template>
<script>
import { Actions, ActionButton } from '@nextcloud/vue'
import AttachmentDragAndDrop from '../AttachmentDragAndDrop'
import relativeDate from '../../mixins/relativeDate'
import { formatFileSize } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import { mapState } from 'vuex'
import { loadState } from '@nextcloud/initial-state'
import attachmentUpload from '../../mixins/attachmentUpload'
const maxUploadSizeState = loadState('deck', 'maxUploadSize')
export default {
name: 'AttachmentList',
components: {
Actions,
ActionButton,
AttachmentDragAndDrop,
},
mixins: [relativeDate],
mixins: [relativeDate, attachmentUpload],
props: {
cardId: {
type: Number,
@@ -84,7 +111,11 @@ export default {
},
data() {
return {
modalShow: false,
file: '',
overwriteAttachment: null,
isDraggingOver: false,
maxUploadSize: maxUploadSizeState,
}
},
computed: {
@@ -101,19 +132,50 @@ export default {
}
},
attachmentUrl() {
return (attachment) => OC.generateUrl(`/apps/deck/cards/${attachment.cardId}/attachment/${attachment.id}`)
return (attachment) => generateUrl(`/apps/deck/cards/${attachment.cardId}/attachment/${attachment.id}`)
},
formattedFileSize() {
return (filesize) => formatFileSize(filesize)
},
...mapState({
currentBoard: state => state.currentBoard,
}),
isReadOnly() {
return !this.$store.getters.canEdit
},
dropHintText() {
if (this.isReadOnly) {
return t('deck', 'This board is read only')
} else {
return t('deck', 'Drop your files to upload')
}
},
},
watch: {
created() {
this.$store.dispatch('fetchAttachments', this.cardId)
},
methods: {
handleUploadFile(event) {
const files = event.target.files ?? []
for (const file of files) {
this.onLocalAttachmentSelected(file)
}
event.target.value = ''
},
clickAddNewAttachmment() {
this.$refs.localAttachments.click()
},
},
}
</script>
<style lang="scss" scoped>
.icon-upload {
padding-left: 35px;
background-position: 10px center;
}
.attachment-list {
&.selector {
padding: 10px;

View File

@@ -104,6 +104,9 @@
<DatetimePicker v-model="duedate"
:placeholder="t('deck', 'Set a due date')"
type="datetime"
:minute-step="5"
:show-second="false"
:format="format"
:disabled="saving || !canEdit"
confirm />
<Actions v-if="canEdit">
@@ -129,11 +132,6 @@
href="https://deck.readthedocs.io/en/latest/Markdown/"
target="_blank"
class="icon icon-info" />
<Actions v-if="canEdit">
<ActionButton v-if="descriptionEditing" icon="icon-attach" @click="showAttachmentModal()">
{{ t('deck', 'Add Attachment') }}
</ActionButton>
</Actions>
<Actions v-if="canEdit">
<ActionButton v-if="!descriptionEditing" icon="icon-rename" @click="showEditor()">
{{ t('deck', 'Edit description') }}
@@ -142,6 +140,11 @@
{{ t('deck', 'View description') }}
</ActionButton>
</Actions>
<Actions v-if="canEdit">
<ActionButton v-if="descriptionEditing" icon="icon-attach" @click="showAttachmentModal()">
{{ t('deck', 'Add Attachment') }}
</ActionButton>
</Actions>
</h5>
<div v-if="!descriptionEditing"
@@ -149,10 +152,12 @@
@click="clickedPreview"
v-html="renderedDescription" />
<VueEasymde v-else
:key="copiedCard.id"
ref="markdownEditor"
:value="copiedCard.description"
v-model="copiedCard.description"
:configs="mdeConfig"
@input="updateDescription" />
@input="updateDescription"
@blur="saveDescription" />
</AppSidebarTab>
<AppSidebarTab id="attachments"
@@ -202,8 +207,13 @@ import MarkdownItTaskLists from 'markdown-it-task-lists'
import { formatFileSize } from '@nextcloud/files'
import relativeDate from '../../mixins/relativeDate'
import AttachmentList from './AttachmentList'
import { generateUrl } from '@nextcloud/router'
import { getLocale } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
const markdownIt = new MarkdownIt()
const markdownIt = new MarkdownIt({
linkify: true,
})
markdownIt.use(MarkdownItTaskLists, { enabled: true, label: true, labelAfter: true })
const capabilities = window.OC.getCapabilities()
@@ -239,6 +249,7 @@ export default {
addedLabelToCard: null,
copiedCard: null,
allLabels: null,
locale: getLocale(),
saving: false,
markdownIt: null,
@@ -250,13 +261,15 @@ export default {
autosave: { enabled: false, uniqueId: 'unique' },
toolbar: false,
},
lastModifiedRelative: null,
lastCreatedRemative: null,
descriptionSaveTimeout: null,
descriptionSaving: false,
hasActivity: capabilities && capabilities.activity,
hasComments: !!OC.appswebroots['comments'],
modalShow: false,
format: {
stringify: this.stringify,
parse: this.parse,
},
}
},
computed: {
@@ -277,7 +290,7 @@ export default {
}
},
attachmentUrl() {
return (attachment) => OC.generateUrl(`/apps/deck/cards/${attachment.cardId}/attachment/${attachment.id}`)
return (attachment) => generateUrl(`/apps/deck/cards/${attachment.cardId}/attachment/${attachment.id}`)
},
formattedFileSize() {
return (filesize) => formatFileSize(filesize)
@@ -286,7 +299,7 @@ export default {
return this.$store.getters.cardById(this.id)
},
subtitle() {
return t('deck', 'Modified') + ': ' + this.lastModifiedRelative + ' ' + t('deck', 'Created') + ': ' + this.lastCreatedRemative
return t('deck', 'Modified') + ': ' + this.relativeDate(this.currentCard.lastModified * 1000) + ' ' + t('deck', 'Created') + ': ' + this.relativeDate(this.currentCard.createdAt * 1000)
},
formatedAssignables() {
return this.assignables.map(item => {
@@ -325,7 +338,7 @@ export default {
},
},
renderedDescription() {
return markdownIt.render(this.copiedCard.description)
return markdownIt.render(this.copiedCard.description || '')
},
},
watch: {
@@ -333,21 +346,19 @@ export default {
this.initialize()
},
},
created() {
setInterval(this.updateRelativeTimestamps, 10000)
},
destroyed() {
clearInterval(this.updateRelativeTimestamps)
},
mounted() {
this.initialize()
},
methods: {
initialize() {
async initialize() {
if (!this.currentCard) {
return
}
if (this.copiedCard) {
await this.saveDescription()
}
this.copiedCard = JSON.parse(JSON.stringify(this.currentCard))
this.allLabels = this.currentCard.labels
@@ -362,7 +373,6 @@ export default {
}
this.desc = this.currentCard.description
this.updateRelativeTimestamps()
},
showEditor() {
if (!this.canEdit) {
@@ -404,13 +414,10 @@ export default {
}
return match
})
this.updateDescription(updatedDescription)
this.$set(this.copiedCard, 'description', updatedDescription)
this.$store.dispatch('updateCardDesc', this.copiedCard)
}
},
updateRelativeTimestamps() {
this.lastModifiedRelative = OC.Util.relativeModifiedDate(this.currentCard.lastModified * 1000)
this.lastCreatedRemative = OC.Util.relativeModifiedDate(this.currentCard.createdAt * 1000)
},
setDue() {
this.$store.dispatch('updateCardDue', this.copiedCard)
},
@@ -418,20 +425,21 @@ export default {
this.copiedCard.duedate = null
this.$store.dispatch('updateCardDue', this.copiedCard)
},
updateDescription(text) {
this.copiedCard.description = text
async saveDescription() {
if (!Object.prototype.hasOwnProperty.call(this.copiedCard, 'descriptionLastEdit') || this.descriptionSaving) {
return
}
this.descriptionSaving = true
await this.$store.dispatch('updateCardDesc', this.copiedCard)
delete this.copiedCard.descriptionLastEdit
this.descriptionSaving = false
},
updateDescription() {
this.copiedCard.descriptionLastEdit = Date.now()
clearTimeout(this.descriptionSaveTimeout)
this.descriptionSaveTimeout = setTimeout(async() => {
if (!Object.prototype.hasOwnProperty.call(this.copiedCard, 'descriptionLastEdit') || this.descriptionSaving) {
return
}
this.descriptionSaving = true
await this.$store.dispatch('updateCardDesc', this.copiedCard)
delete this.copiedCard.descriptionLastEdit
this.descriptionSaving = false
await this.saveDescription()
}, 2500)
},
closeSidebar() {
@@ -482,17 +490,25 @@ export default {
}
this.$store.dispatch('removeLabel', data)
},
stringify(date) {
return moment(date).locale(this.locale).format('LLL')
},
parse(value) {
return moment(value, 'LLL', this.locale).toDate()
},
},
}
</script>
<style>
@import "~easymde/dist/easymde.min.css";
@import '~easymde/dist/easymde.min.css';
.vue-easymde, .CodeMirror {
border: none;
margin: 0;
padding: 0;
}
.editor-preview,
.editor-statusbar {
display: none;
@@ -562,8 +578,8 @@ export default {
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
padding: 1px 3px;
border-radius: 3px;
padding: 0px 5px;
border-radius: 15px;
font-size: 85%;
margin-right: 3px;
}
@@ -600,23 +616,33 @@ export default {
#description-preview {
min-height: 100px;
&::v-deep {
@import './../../css/markdown';
}
&::v-deep input {
min-height: auto;
}
&::v-deep a {
text-decoration: underline;
}
}
.modal__content {
width: 25vw;
min-width: 250px;
height: 120px;
text-align: center;
margin: 20px 20px 60px 20px;
min-height: 120px;
margin: 20px;
padding-bottom: 20px;
}
display: flex;
flex-direction: column;
.modal__content button {
float: right;
margin: 40px 3px 3px 0;
&::v-deep .attachment-list {
flex-shrink: 1;
overflow: scroll;
max-height: 50vh;
}
}
</style>

View File

@@ -21,113 +21,27 @@
-->
<template>
<AttachmentDragAndDrop :card-id="card.id" class="drop-upload--sidebar">
<button class="icon-upload" @click="clickAddNewAttachmment()">
{{ t('settings', 'Upload attachment') }}
</button>
<input ref="localAttachments"
type="file"
style="display: none;"
multiple
@change="handleUploadFile">
<div class="attachment-list">
<ul>
<li v-for="attachment in uploadQueue" :key="attachment.name" class="attachment">
<a class="fileicon" :style="mimetypeForAttachment('none')" />
<div class="details">
<a>
<div class="filename">
<span class="basename">{{ attachment.name }}</span>
</div>
<progress :value="attachment.progress" max="100" />
</a>
</div>
</li>
<AttachmentList
:card-id="card.id"
:removable="true"
@deleteAttachment="deleteAttachment"
@restoreAttachment="restoreAttachment" />
</ul>
</div>
</AttachmentDragAndDrop>
<AttachmentList
:card-id="card.id"
:removable="true"
@deleteAttachment="deleteAttachment"
@restoreAttachment="restoreAttachment" />
</template>
<script>
import { mapState } from 'vuex'
import { loadState } from '@nextcloud/initial-state'
import AttachmentDragAndDrop from '../AttachmentDragAndDrop'
import attachmentUpload from '../../mixins/attachmentUpload'
import AttachmentList from './AttachmentList'
const maxUploadSizeState = loadState('deck', 'maxUploadSize')
export default {
name: 'CardSidebarTabAttachments',
components: {
AttachmentDragAndDrop,
AttachmentList,
},
mixins: [ attachmentUpload ],
props: {
card: {
type: Object,
default: null,
},
},
data() {
return {
modalShow: false,
file: '',
overwriteAttachment: null,
isDraggingOver: false,
maxUploadSize: maxUploadSizeState,
}
},
computed: {
...mapState({
currentBoard: state => state.currentBoard,
}),
isReadOnly() {
return !this.$store.getters.canEdit
},
dropHintText() {
if (this.isReadOnly) {
return t('deck', 'This board is read only')
} else {
return t('deck', 'Drop your files to upload')
}
},
attachments() {
return [...this.$store.getters.attachmentsByCard(this.card.id)].sort((a, b) => b.id - a.id)
},
mimetypeForAttachment() {
return (mimetype) => {
const url = OC.MimeType.getIconUrl(mimetype)
const styles = {
'background-image': `url("${url}")`,
}
return styles
}
},
cardId() {
return this.card.id
},
},
created: function() {
this.$store.dispatch('fetchAttachments', this.card.id)
},
methods: {
handleUploadFile(event) {
const files = event.target.files ?? []
for (const file of files) {
this.onLocalAttachmentSelected(file)
}
event.target.value = ''
},
clickAddNewAttachmment() {
this.$refs.localAttachments.click()
},
deleteAttachment(attachment) {
this.$store.dispatch('deleteAttachment', attachment)
},
@@ -137,94 +51,3 @@ export default {
},
}
</script>
<style scoped lang="scss">
.icon-upload {
padding-left: 35px;
background-position: 10px center;
}
.attachment-list {
&.selector {
padding: 10px;
position: absolute;
width: 30%;
max-width: 500px;
min-width: 200px;
max-height: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #eee;
z-index: 2;
border-radius: 3px;
box-shadow: 0 0 3px darkgray;
overflow: scroll;
}
h3.attachment-selector {
margin: 0 0 10px;
padding: 0;
.icon-close {
display: inline-block;
float: right;
}
}
li.attachment {
display: flex;
padding: 3px;
min-height: 44px;
&.deleted {
opacity: .5;
}
.fileicon {
display: inline-block;
min-width: 32px;
width: 32px;
height: 32px;
background-size: contain;
}
.details {
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
flex-basis: 50%;
line-height: 110%;
padding: 2px;
}
.filename {
width: 70%;
display: flex;
.basename {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-bottom: 2px;
}
.extension {
opacity: 0.7;
}
}
.filesize, .filedate {
font-size: 90%;
color: darkgray;
}
.app-popover-menu-utils {
position: relative;
right: -10px;
button {
height: 32px;
width: 42px;
}
}
button.icon-history {
width: 44px;
}
progress {
margin-top: 3px;
}
}
}
</style>

View File

@@ -35,7 +35,7 @@ import { Avatar } from '@nextcloud/vue'
import CommentItem from './CommentItem'
import CommentForm from './CommentForm'
import InfiniteLoading from 'vue-infinite-loading'
import { getCurrentUser } from '@nextcloud/auth'
export default {
name: 'CardSidebarTabComments',
components: {
@@ -54,7 +54,7 @@ export default {
return {
newComment: '',
isLoading: false,
currentUser: OC.getCurrentUser(),
currentUser: getCurrentUser(),
}
},
computed: {
@@ -115,5 +115,5 @@ export default {
</script>
<style scoped lang="scss">
@import "../../css/comments";
@import '../../css/comments';
</style>

View File

@@ -173,7 +173,7 @@ export default {
</script>
<style scoped lang="scss">
@import "../../css/comments";
@import '../../css/comments';
.atwho-wrap {
width: 100%;

View File

@@ -172,7 +172,7 @@ export default {
</script>
<style scoped lang="scss">
@import "../../css/comments";
@import '../../css/comments';
.reply {
border-left: 3px solid var(--color-primary-element);
@@ -195,6 +195,7 @@ export default {
margin: 0;
}
}
.comment--content::v-deep a {
text-decoration: underline;
}

View File

@@ -58,6 +58,7 @@
<script>
import { Avatar, PopoverMenu, Tooltip } from '@nextcloud/vue'
import { generateUrl } from '@nextcloud/router'
export default {
name: 'AvatarList',
@@ -93,10 +94,10 @@ export default {
}
const user = assignable.participant.uid
const size = 32
const avatarUrl = OC.generateUrl('/avatar/{user}/{size}',
const avatarUrl = generateUrl('/avatar/{user}/{size}',
{
user: user,
size: size,
user,
size,
})
return window.location.protocol + '//' + window.location.host + avatarUrl
}
@@ -142,6 +143,7 @@ export default {
}
}
}
.avatar-list {
float: right;
display: inline-flex;
@@ -152,6 +154,9 @@ export default {
width: 36px;
height: 36px;
box-sizing: content-box !important;
margin-right: -12px;
transition: margin-right 0.2s ease-in-out;
&.icon-more {
width: 32px;
height: 32px;
@@ -159,15 +164,12 @@ export default {
background-color: var(--color-background-dark) !important;
cursor: pointer;
}
& {
margin-right: -12px;
transition: margin-right 0.2s ease-in-out;
}
}
&:hover div:nth-child(n+2) /deep/ .avatardiv {
margin-right: 1px;
}
}
.popovermenu {
display: block;
margin: 40px -6px;

View File

@@ -34,138 +34,32 @@
<AvatarList :users="card.assignedUsers" />
<div @click.stop.prevent>
<Actions v-if="canEdit">
<ActionButton v-if="showArchived === false" icon="icon-user" @click="assignCardToMe()">
{{ t('deck', 'Assign to me') }}
</ActionButton>
<ActionButton icon="icon-archive" @click="archiveUnarchiveCard()">
{{ t('deck', (showArchived ? 'Unarchive card' : 'Archive card')) }}
</ActionButton>
<ActionButton v-if="showArchived === false" icon="icon-delete" @click="deleteCard()">
{{ t('deck', 'Delete card') }}
</ActionButton>
<ActionButton icon="icon-external" @click.stop="modalShow=true">
{{ t('deck', 'Move card') }}
</ActionButton>
<ActionButton icon="icon-settings-dark" @click="openCard">
{{ t('deck', 'Card details') }}
</ActionButton>
</Actions>
</div>
<Modal v-if="modalShow" title="Move card to another board" @close="modalShow=false">
<div class="modal__content">
<Multiselect v-model="selectedBoard"
:placeholder="t('deck', 'Select a board')"
:options="boards"
label="title"
@select="loadStacksFromBoard" />
<Multiselect v-model="selectedStack"
:placeholder="t('deck', 'Select a stack')"
:options="stacksFromBoard"
label="title" />
<button :disabled="!isBoardAndStackChoosen" class="primary" @click="moveCard">
{{ t('deck', 'Move card') }}
</button>
<button @click="modalShow=false">
{{ t('deck', 'Cancel') }}
</button>
</div>
</Modal>
<CardMenu :id="id" />
</div>
</template>
<script>
import AvatarList from './AvatarList'
import { Modal, Actions, ActionButton, Multiselect } from '@nextcloud/vue'
import { mapGetters, mapState } from 'vuex'
import axios from '@nextcloud/axios'
import CardMenu from './CardMenu'
export default {
name: 'CardBadges',
components: { AvatarList, Actions, ActionButton, Modal, Multiselect },
components: { AvatarList, CardMenu },
props: {
id: {
type: Number,
default: null,
},
},
data() {
return {
modalShow: false,
selectedBoard: '',
selectedStack: '',
stacksFromBoard: [],
}
},
computed: {
...mapGetters([
'canEdit',
]),
...mapState({
showArchived: state => state.showArchived,
currentBoard: state => state.currentBoard,
}),
checkListCount() {
return (this.card.description.match(/^\s*(\*|-|(\d\.))\s+\[\s*(\s|x)\s*\](.*)$/gim) || []).length
return (this.card.description.match(/^\s*([*+-]|(\d\.))\s+\[\s*(\s|x)\s*\](.*)$/gim) || []).length
},
checkListCheckedCount() {
return (this.card.description.match(/^\s*(\*|-|(\d\.))\s+\[\s*x\s*\](.*)$/gim) || []).length
},
compactMode() {
return false
return (this.card.description.match(/^\s*([*+-]|(\d\.))\s+\[\s*x\s*\](.*)$/gim) || []).length
},
card() {
return this.$store.getters.cardById(this.id)
},
isBoardAndStackChoosen() {
if (this.selectedBoard === '' || this.selectedStack === '') {
return false
}
return true
},
boards() {
return this.$store.getters.boards.filter(board => {
return board.id !== this.currentBoard.id
})
},
},
methods: {
openCard() {
this.$router.push({ name: 'card', params: { cardId: this.id } })
},
deleteCard() {
this.$store.dispatch('deleteCard', this.card)
},
archiveUnarchiveCard() {
this.$store.dispatch('archiveUnarchiveCard', { ...this.card, archived: !this.card.archived })
},
assignCardToMe() {
this.copiedCard = Object.assign({}, this.card)
this.$store.dispatch('assignCardToUser', {
card: this.copiedCard,
assignee: {
userId: OC.getCurrentUser().uid,
type: 0,
},
})
},
moveCard() {
this.copiedCard = Object.assign({}, this.card)
this.copiedCard.stackId = this.selectedStack.id
this.$store.dispatch('moveCard', this.copiedCard)
this.modalShow = false
},
async loadStacksFromBoard(board) {
try {
console.debug(board)
const url = OC.generateUrl('/apps/deck/stacks/' + board.id)
const response = await axios.get(url)
this.stacksFromBoard = response.data
} catch (err) {
return err
}
},
},
}
</script>
@@ -233,23 +127,8 @@ export default {
.fade-enter-active, .fade-leave-active {
transition: opacity .125s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
.modal__content {
width: 25vw;
min-width: 250px;
height: 120px;
text-align: center;
margin: 20px 20px 60px 20px;
.multiselect {
margin-bottom: 10px;
}
}
.modal__content button {
float: right;
}
</style>

View File

@@ -31,7 +31,7 @@
class="card"
@click="openCard">
<div class="card-upper">
<h3 v-if="showArchived || !canEdit">
<h3 v-if="isArchived || showArchived || !canEdit">
{{ card.title }}
</h3>
<h3 v-else-if="!editing">
@@ -47,15 +47,18 @@
<input type="button" class="icon-confirm" @click="finishedEdit(card)">
</form>
<div v-if="!editing" class="right">
<div v-if="!editing" class="duedate right">
<transition name="zoom">
<div v-if="card.duedate" :class="dueIcon">
<span>{{ relativeDate }}</span>
</div>
</transition>
</div>
<CardMenu v-if="!editing && compactMode" :id="id" class="right" />
</div>
<transition-group name="zoom"
<transition-group v-if="card.labels.length"
name="zoom"
tag="ul"
class="labels"
@click="openCard">
@@ -78,10 +81,11 @@ import CardBadges from './CardBadges'
import Color from '../../mixins/color'
import labelStyle from '../../mixins/labelStyle'
import AttachmentDragAndDrop from '../AttachmentDragAndDrop'
import CardMenu from './CardMenu'
export default {
name: 'CardItem',
components: { CardBadges, AttachmentDragAndDrop },
components: { CardBadges, AttachmentDragAndDrop, CardMenu },
directives: {
ClickOutside,
},
@@ -106,6 +110,7 @@ export default {
}),
...mapGetters([
'canEdit',
'isArchived',
]),
card() {
return this.$store.getters.cardById(this.id)
@@ -137,16 +142,9 @@ export default {
return moment(this.card.duedate).format('LLLL')
},
},
watch: {
currentCard(newValue) {
if (newValue) {
this.$nextTick(() => this.$el.scrollIntoView())
}
},
},
methods: {
openCard() {
this.$router.push({ name: 'card', params: { cardId: this.id } })
this.$router.push({ name: 'card', params: { cardId: this.id } }).catch(() => {})
},
startEditing(card) {
this.copiedCard = Object.assign({}, card)
@@ -175,6 +173,10 @@ export default {
border: 1px solid var(--color-border);
}
.card:hover {
box-shadow: 0 0 5px 1px var(--color-box-shadow);
}
.card {
transition: box-shadow 0.1s ease-in-out;
box-shadow: 0 0 2px 0 var(--color-box-shadow);
@@ -189,7 +191,7 @@ export default {
.card-upper {
display: flex;
min-height: 50px;
min-height: 44px;
form {
display: flex;
padding: 5px 7px;
@@ -200,7 +202,7 @@ export default {
}
h3 {
margin: 14px $card-padding;
margin: 12px $card-padding;
flex-grow: 1;
font-size: 100%;
overflow-x: hidden;
@@ -227,7 +229,7 @@ export default {
display: flex;
flex-direction: row;
overflow: hidden;
padding: 3px 7px;
padding: 0px 5px;
border-radius: 15px;
font-size: 85%;
margin-right: 3px;
@@ -257,10 +259,14 @@ export default {
}
}
}
.duedate {
margin-right: 9px;
}
.right {
display: flex;
align-items: flex-start;
margin-right: 9px;
}
.icon.due {
@@ -269,6 +275,7 @@ export default {
margin-top: 9px;
margin-bottom: 9px;
padding: 3px 4px;
padding-right: 0;
font-size: 90%;
display: flex;
align-items: center;
@@ -284,14 +291,17 @@ export default {
background-color: var(--color-error);
color: var(--color-primary-text);
opacity: .7;
padding: 3px 4px;
}
&.now {
background-color: var(--color-warning);
opacity: .7;
padding: 3px 4px;
}
&.next {
background-color: var(--color-background-dark);
opacity: .7;
padding: 3px 4px;
}
span {
@@ -303,8 +313,11 @@ export default {
}
.compact {
min-height: 50px;
min-height: 44px;
.duedate {
margin-right: 0;
}
&.has-labels {
padding-bottom: $card-padding;
}

View File

@@ -0,0 +1,188 @@
<!--
- @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
-
- @author Julius Härtl <jus@bitgrid.net>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div>
<div @click.stop.prevent>
<Actions v-if="canEdit && !isArchived">
<ActionButton v-if="showArchived === false && !isCurrentUserAssigned" icon="icon-user" @click="assignCardToMe()">
{{ t('deck', 'Assign to me') }}
</ActionButton>
<ActionButton v-if="showArchived === false && isCurrentUserAssigned" icon="icon-user" @click="unassignCardFromMe()">
{{ t('deck', 'Unassign myself') }}
</ActionButton>
<ActionButton icon="icon-archive" @click="archiveUnarchiveCard()">
{{ showArchived ? t('deck', 'Unarchive card') : t('deck', 'Archive card') }}
</ActionButton>
<ActionButton v-if="showArchived === false" icon="icon-delete" @click="deleteCard()">
{{ t('deck', 'Delete card') }}
</ActionButton>
<ActionButton icon="icon-external" @click.stop="modalShow=true">
{{ t('deck', 'Move card') }}
</ActionButton>
<ActionButton icon="icon-settings-dark" @click="openCard">
{{ t('deck', 'Card details') }}
</ActionButton>
</Actions>
</div>
<Modal v-if="modalShow" :title="t('deck', 'Move card to another board')" @close="modalShow=false">
<div class="modal__content">
<h3>{{ t('deck', 'Move card to another board') }}</h3>
<Multiselect v-model="selectedBoard"
:placeholder="t('deck', 'Select a board')"
:options="boards"
:max-height="100"
label="title"
@select="loadStacksFromBoard" />
<Multiselect v-model="selectedStack"
:placeholder="t('deck', 'Select a list')"
:options="stacksFromBoard"
:max-height="100"
label="title" />
<button :disabled="!isBoardAndStackChoosen" class="primary" @click="moveCard">
{{ t('deck', 'Move card') }}
</button>
<button @click="modalShow=false">
{{ t('deck', 'Cancel') }}
</button>
</div>
</Modal>
</div>
</template>
<script>
import { Modal, Actions, ActionButton, Multiselect } from '@nextcloud/vue'
import { mapGetters, mapState } from 'vuex'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
export default {
name: 'CardMenu',
components: { Actions, ActionButton, Modal, Multiselect },
props: {
id: {
type: Number,
default: null,
},
},
data() {
return {
modalShow: false,
selectedBoard: '',
selectedStack: '',
stacksFromBoard: [],
}
},
computed: {
...mapGetters([
'canEdit',
'isArchived',
]),
...mapState({
showArchived: state => state.showArchived,
currentBoard: state => state.currentBoard,
}),
card() {
return this.$store.getters.cardById(this.id)
},
isBoardAndStackChoosen() {
if (this.selectedBoard === '' || this.selectedStack === '') {
return false
}
return true
},
boards() {
return this.$store.getters.boards.filter(board => {
return board.id !== this.currentBoard.id
})
},
isCurrentUserAssigned() {
return this.card.assignedUsers.find((item) => item.type === 0 && item.participant.uid === getCurrentUser()?.uid)
},
},
methods: {
openCard() {
this.$router.push({ name: 'card', params: { cardId: this.id } })
},
deleteCard() {
this.$store.dispatch('deleteCard', this.card)
},
archiveUnarchiveCard() {
this.$store.dispatch('archiveUnarchiveCard', { ...this.card, archived: !this.card.archived })
},
assignCardToMe() {
this.$store.dispatch('assignCardToUser', {
card: this.card,
assignee: {
userId: getCurrentUser()?.uid,
type: 0,
},
})
},
unassignCardFromMe() {
this.$store.dispatch('removeUserFromCard', {
card: this.card,
assignee: {
userId: getCurrentUser()?.uid,
type: 0,
},
})
},
moveCard() {
this.copiedCard = Object.assign({}, this.card)
this.copiedCard.stackId = this.selectedStack.id
this.$store.dispatch('moveCard', this.copiedCard)
this.modalShow = false
},
async loadStacksFromBoard(board) {
try {
const url = generateUrl('/apps/deck/stacks/' + board.id)
const response = await axios.get(url)
this.stacksFromBoard = response.data
} catch (err) {
return err
}
},
},
}
</script>
<style lang="scss" scoped>
.modal__content {
width: 25vw;
min-width: 250px;
min-height: 120px;
text-align: center;
margin: 20px 20px 100px 20px;
.multiselect {
margin-bottom: 10px;
}
}
.modal__content button {
float: right;
margin-top: 50px;
}
</style>

View File

@@ -21,67 +21,66 @@
-->
<template>
<div id="app-navigation" :class="{'icon-loading': loading}">
<ul id="deck-navigation">
<AppNavigationVue :class="{'icon-loading': loading}">
<template #list>
<AppNavigationBoardCategory
id="deck-navigation-all"
to="/board"
:text="t('deck', 'All boards')"
:boards="noneArchivedBoards"
:open-on-add-boards="true"
icon="icon-deck" />
<AppNavigationBoardCategory
id="deck-navigation-archived"
to="/board/archived"
:text="t('deck', 'Archived boards')"
:boards="archivedBoards"
icon="icon-archive" />
<AppNavigationBoardCategory
id="deck-navigation-shared"
:text="t('deck', 'Shared boards')"
to="/board/shared"
:text="t('deck', 'Shared with you')"
:boards="sharedBoards"
icon="icon-shared" />
<AppNavigationAddBoard v-if="canCreate" />
</ul>
<div v-if="isAdmin"
id="app-settings"
v-click-outside="closeMenu"
:class="{open: opened}">
<div id="app-settings-header">
<button class="settings-button" @click="toggleMenu">
{{ t('deck', 'Settings') }}
</button>
</div>
<div id="app-settings-content">
<Multiselect v-model="groupLimit"
:class="{'icon-loading-small': groupLimitDisabled}"
open-direction="bottom"
:options="groups"
:multiple="true"
:disabled="groupLimitDisabled"
:placeholder="t('deck', 'Limit deck usage of groups')"
label="displayname"
track-by="id"
@input="updateConfig" />
<p>{{ t('deck', 'Limiting Deck will block users not part of those groups from creating their own boards. Users will still be able to work on boards that have been shared with them.') }}</p>
</div>
</div>
</div>
</template>
<template #footer>
<AppNavigationSettings>
<div>
<Multiselect v-model="groupLimit"
:class="{'icon-loading-small': groupLimitDisabled}"
open-direction="bottom"
:options="groups"
:multiple="true"
:disabled="groupLimitDisabled"
:placeholder="t('deck', 'Limit deck usage of groups')"
label="displayname"
track-by="id"
@input="updateConfig" />
<p>{{ t('deck', 'Limiting Deck will block users not part of those groups from creating their own boards. Users will still be able to work on boards that have been shared with them.') }}</p>
</div>
</AppNavigationSettings>
</template>
</AppNavigationVue>
</template>
<script>
import axios from '@nextcloud/axios'
import { mapGetters } from 'vuex'
import ClickOutside from 'vue-click-outside'
import { Multiselect } from '@nextcloud/vue'
import { AppNavigation as AppNavigationVue, AppNavigationSettings, Multiselect } from '@nextcloud/vue'
import AppNavigationAddBoard from './AppNavigationAddBoard'
import AppNavigationBoardCategory from './AppNavigationBoardCategory'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl, generateOcsUrl } from '@nextcloud/router'
const canCreateState = loadState('deck', 'canCreate')
export default {
name: 'AppNavigation',
components: {
AppNavigationVue,
AppNavigationSettings,
AppNavigationAddBoard,
AppNavigationBoardCategory,
Multiselect,
@@ -118,13 +117,13 @@ export default {
},
beforeMount() {
if (this.isAdmin) {
axios.get(OC.generateUrl('apps/deck/config')).then((response) => {
axios.get(generateUrl('apps/deck/config')).then((response) => {
this.groupLimit = response.data.groupLimit
this.groupLimitDisabled = false
}, (error) => {
console.error('Error while loading groupLimit', error.response)
})
axios.get(OC.linkToOCS('cloud', 2) + 'groups').then((response) => {
axios.get(generateOcsUrl('cloud', 2) + 'groups').then((response) => {
this.groups = response.data.ocs.data.groups.reduce((obj, item) => {
obj.push({
id: item,
@@ -138,15 +137,9 @@ export default {
}
},
methods: {
toggleMenu() {
this.opened = !this.opened
},
closeMenu() {
this.opened = false
},
updateConfig() {
this.groupLimitDisabled = true
axios.post(OC.generateUrl('apps/deck/config/groupLimit'), {
axios.post(generateUrl('apps/deck/config/groupLimit'), {
value: this.groupLimit,
}).then(() => {
this.groupLimitDisabled = false
@@ -165,4 +158,8 @@ export default {
color: var(--color-text-light);
}
}
::v-deep .app-navigation-toggle {
display: none;
}
</style>

View File

@@ -20,38 +20,30 @@
-
-->
<template>
<li id="deck-navigation-add"
<AppNavigationItem v-if="!editing"
:title="t('deck', 'Create new board')"
:class="[{'icon-loading-small': loading, 'editing': editing}, classes]">
<a class="icon-add" href="#" @click.prevent.stop="startCreateBoard">
{{ t('deck', 'Create new board') }}
</a>
<!-- edit entry -->
<div v-if="editing" class="app-navigation-entry-edit">
<ColorPicker v-model="color" class="app-navigation-entry-bullet-wrapper">
<div :style="{ backgroundColor: color }" class="color0 icon-colorpicker app-navigation-entry-bullet" />
</ColorPicker>
<form @submit.prevent.stop="createBoard">
<input :placeholder="t('deck', 'New board title')" type="text" required>
<input type="submit" value="" class="icon-confirm">
<input type="submit"
value=""
class="icon-close"
@click.stop.prevent="cancelEdit">
</form>
<!-- <ColorPicker v-model="color" /> -->
</div>
</li>
icon="icon-add"
@click.prevent.stop="startCreateBoard" />
<div v-else class="board-create">
<ColorPicker v-model="color" class="app-navigation-entry-bullet-wrapper">
<div :style="{ backgroundColor: color }" class="color0 icon-colorpicker app-navigation-entry-bullet" />
</ColorPicker>
<form @submit.prevent.stop="createBoard">
<input :placeholder="t('deck', 'New board title')" type="text" required>
<input type="submit" value="" class="icon-confirm">
<Actions><ActionButton icon="icon-close" @click.stop.prevent="cancelEdit" /></Actions>
</form>
</div>
</template>
<script>
import { ColorPicker } from '@nextcloud/vue'
import { ColorPicker, ActionButton, Actions, AppNavigationItem } from '@nextcloud/vue'
const randomColor = () => '#' + ((1 << 24) * Math.random() | 0).toString(16)
export default {
name: 'AppNavigationAddBoard',
components: { ColorPicker },
components: { ColorPicker, AppNavigationItem, ActionButton, Actions },
directives: {},
props: {},
data() {
@@ -59,7 +51,7 @@ export default {
classes: [],
editing: false,
loading: false,
color: '#000000',
color: randomColor(),
}
},
computed: {},
@@ -72,31 +64,42 @@ export default {
createBoard(e) {
const title = e.currentTarget.childNodes[0].value
this.$store.dispatch('createBoard', {
title: title,
title,
color: this.color.substring(1),
})
this.editing = false
this.color = randomColor()
},
cancelEdit(e) {
this.editing = false
this.item.edit.reset(e)
this.color = randomColor()
},
},
}
</script>
<style lang="scss" scoped>
#app-navigation .app-navigation-entry-edit div {
width: auto;
display: block;
.board-create {
order: 1;
display: flex;
height: 44px;
form {
display: flex;
flex-grow: 1;
input[type='text'] {
flex-grow: 1;
}
}
}
.app-navigation-entry-bullet-wrapper {
position: absolute;
left: 33px;
width: 44px !important;
margin: 6px;
width: 44px;
height: 44px;
.color0 {
width: 30px !important;
margin: 5px;
margin-left: 7px;
height: 30px;
border-radius: 50%;
background-size: 14px;

View File

@@ -20,75 +20,80 @@
-
-->
<template>
<router-link :id="`board-${board.id}`"
:title="board.title"
:class="[{'icon-loading-small': loading, deleted: deleted, editing: editing }, classes]"
<AppNavigationItem v-if="!editing"
:title="!deleted ? board.title : undoText"
:loading="loading"
:to="routeTo"
tag="li">
<div :style="{ backgroundColor: `#${board.color}` }" class="app-navigation-entry-bullet" />
<a href="#">
{{ board.title }}
</a>
:undo="deleted"
@undo="unDelete">
<AppNavigationIconBullet slot="icon" :color="board.color" />
<div v-if="actions.length > 0" class="app-navigation-entry-utils">
<ul>
<li class="app-navigation-entry-utils-menu-button">
<button v-if="board.acl.length === 0"
v-tooltip="t('deck', 'Share')"
class="icon-shared"
style="opacity: 0.3"
@click="showSidebar" />
<button v-else
v-tooltip="t('deck', 'Share')"
class="icon-shared"
@click="showSidebar" />
</li>
<li class="app-navigation-entry-utils-menu-button">
<button v-click-outside="hideMenu" v-tooltip="t('deck', 'Options')" @click="showMenu" />
</li>
</ul>
</div>
<div :class="{ 'open': menuOpen }" class="app-navigation-entry-menu">
<PopoverMenu :menu="actions" />
</div>
<AppNavigationCounter v-if="board.acl.length"
slot="counter"
class="icon-shared"
style="opacity: 0.5" />
<!-- undo action -->
<div v-if="deleted" class="app-navigation-entry-deleted">
<div class="app-navigation-entry-deleted-description">
{{ undoText }}
</div>
<button
:title="t('settings', 'Undo')"
class="app-navigation-entry-deleted-button icon-history"
@click="unDelete" />
</div>
<!-- edit entry -->
<div v-if="editing" class="app-navigation-entry-edit">
<ColorPicker class="app-navigation-entry-bullet-wrapper" :value="`#${board.color}`" @input="updateColor">
<div :style="{ backgroundColor: getColor }" class="color0 icon-colorpicker app-navigation-entry-bullet" />
</ColorPicker>
<form @submit.prevent.stop="applyEdit">
<input v-model="editTitle" type="text" required>
<input type="submit" value="" class="icon-confirm">
<input type="submit"
value=""
class="icon-close"
@click.stop.prevent="cancelEdit">
</form>
</div>
</router-link>
<template v-if="!deleted" slot="actions">
<ActionButton v-if="canManage && !board.archived"
icon="icon-rename"
:close-after-click="true"
@click="actionEdit">
{{ t('deck', 'Edit board') }}
</ActionButton>
<ActionButton v-if="canManage && !board.archived"
icon="icon-clone"
:close-after-click="true"
@click="actionClone">
{{ t('deck', 'Clone board ') }}
</ActionButton>
<ActionButton v-if="canManage && board.archived"
icon="icon-archive"
:close-after-click="true"
@click="actionUnarchive">
{{ t('deck', 'Unarchive board ') }}
</ActionButton>
<ActionButton v-if="canManage && !board.archived"
icon="icon-archive"
:close-after-click="true"
@click="actionArchive">
{{ t('deck', 'Archive board ') }}
</ActionButton>
<ActionButton v-if="canManage"
icon="icon-delete"
:close-after-click="true"
@click="actionDelete">
{{ t('deck', 'Delete board ') }}
</ActionButton>
<ActionButton icon="icon-more" :close-after-click="true" @click="actionDetails">
{{ t('deck', 'Board details') }}
</ActionButton>
</template>
</AppNavigationItem>
<div v-else-if="editing" class="board-edit">
<ColorPicker class="app-navigation-entry-bullet-wrapper" :value="`#${board.color}`" @input="updateColor">
<div :style="{ backgroundColor: getColor }" class="color0 icon-colorpicker app-navigation-entry-bullet" />
</ColorPicker>
<form @submit.prevent.stop="applyEdit">
<input v-model="editTitle" type="text" required>
<input type="submit" value="" class="icon-confirm">
<Actions><ActionButton icon="icon-close" @click.stop.prevent="cancelEdit" /></Actions>
</form>
</div>
</template>
<script>
import { PopoverMenu, ColorPicker } from '@nextcloud/vue'
import { AppNavigationIconBullet, AppNavigationCounter, AppNavigationItem, ColorPicker, Actions, ActionButton } from '@nextcloud/vue'
import ClickOutside from 'vue-click-outside'
export default {
name: 'AppNavigationBoard',
components: {
AppNavigationIconBullet,
AppNavigationCounter,
AppNavigationItem,
ColorPicker,
PopoverMenu,
Actions,
ActionButton,
},
directives: {
ClickOutside,
@@ -118,126 +123,17 @@ export default {
}
return this.board.color
},
undoText: function() {
// todo translation
return 'deleted ' + this.board.title
undoText() {
return t('deck', 'Board {0} deleted', [this.board.title])
},
routeTo: function() {
routeTo() {
return {
name: 'board',
params: { id: this.board.id },
}
},
actions: function() {
/* eslint-disable vue/no-side-effects-in-computed-properties */
/* eslint-disable vue/no-async-in-computed-properties */
const actions = []
// do not show actions while the item is loading
if (this.loading === false) {
const canManage = this.board.permissions.PERMISSION_MANAGE
if (canManage) {
actions.push({
action: () => {
this.hideMenu()
this.editTitle = this.board.title
this.editColor = '#' + this.board.color
this.editing = true
},
icon: 'icon-rename',
text: t('deck', 'Edit board'),
})
}
actions.push({
action: async() => {
this.hideMenu()
this.loading = true
try {
const newBoard = await this.$store.dispatch('cloneBoard', this.board)
this.loading = false
const route = this.routeTo
route.params.id = newBoard.id
this.$router.push(route)
} catch (e) {
OC.Notification.showTemporary(t('deck', 'An error occurred'))
console.error(e)
}
},
icon: 'icon-clone',
text: t('deck', 'Clone board'),
})
if (canManage) {
if (!this.board.archived) {
actions.push({
action: () => {
this.hideMenu()
this.loading = true
this.$store.dispatch('archiveBoard', this.board)
},
icon: 'icon-archive',
text: t('deck', 'Archive board'),
})
} else {
actions.push({
action: () => {
this.hideMenu()
this.loading = true
this.$store.dispatch('unarchiveBoard', this.board)
},
icon: 'icon-archive',
text: t('deck', 'Unarchive board'),
})
}
actions.push({
action: () => {
OC.dialogs.confirmDestructive(
t('deck', 'Are you sure you want to delete the board {title}? This will delete all the data of this board.', { title: this.board.title }),
t('deck', 'Delete the board?'),
{
type: OC.dialogs.YES_NO_BUTTONS,
confirm: t('deck', 'Delete'),
confirmClasses: 'error',
cancel: t('deck', 'Cancel'),
},
(result) => {
if (result) {
this.hideMenu()
this.loading = true
this.boardApi.deleteBoard(this.board)
.then(() => {
this.loading = false
this.deleted = true
this.undoTimeoutHandle = setTimeout(() => {
this.$store.dispatch('removeBoard', this.board)
}, 7000)
})
}
},
true
)
},
icon: 'icon-delete',
text: t('deck', 'Delete board'),
})
}
actions.push({
action: () => {
const route = this.routeTo
route.name = 'board.details'
this.$router.push(route)
},
icon: 'icon-settings-dark',
text: t('deck', 'Board details'),
})
}
return actions
canManage() {
return this.board.permissions.PERMISSION_MANAGE
},
},
watch: {},
@@ -253,15 +149,66 @@ export default {
this.deleted = false
})
},
showMenu() {
this.menuOpen = true
},
hideMenu() {
this.menuOpen = false
},
updateColor(newColor) {
this.editColor = newColor
},
actionEdit() {
this.editTitle = this.board.title
this.editColor = '#' + this.board.color
this.editing = true
},
async actionClone() {
this.loading = true
try {
const newBoard = await this.$store.dispatch('cloneBoard', this.board)
this.loading = false
const route = this.routeTo
route.params.id = newBoard.id
this.$router.push(route)
} catch (e) {
OC.Notification.showTemporary(t('deck', 'An error occurred'))
console.error(e)
}
},
actionArchive() {
this.loading = true
this.$store.dispatch('archiveBoard', this.board)
},
actionUnarchive() {
this.loading = true
this.$store.dispatch('unarchiveBoard', this.board)
},
actionDelete() {
OC.dialogs.confirmDestructive(
t('deck', 'Are you sure you want to delete the board {title}? This will delete all the data of this board.', { title: this.board.title }),
t('deck', 'Delete the board?'),
{
type: OC.dialogs.YES_NO_BUTTONS,
confirm: t('deck', 'Delete'),
confirmClasses: 'error',
cancel: t('deck', 'Cancel'),
},
(result) => {
if (result) {
this.loading = true
this.boardApi.deleteBoard(this.board)
.then(() => {
this.loading = false
this.deleted = true
this.undoTimeoutHandle = setTimeout(() => {
this.$store.dispatch('removeBoard', this.board)
}, 7000)
})
}
},
true
)
},
actionDetails() {
const route = this.routeTo
route.name = 'board.details'
this.$router.push(route)
},
applyEdit(e) {
this.editing = false
if (this.editTitle || this.editColor) {
@@ -291,17 +238,29 @@ export default {
</script>
<style lang="scss" scoped>
#app-navigation #deck-navigation .editing {
height: auto !important;
.board-edit {
margin-left: 44px;
order: 1;
display: flex;
height: 44px;
form {
display: flex;
flex-grow: 1;
input[type='text'] {
flex-grow: 1;
}
}
}
.app-navigation-entry-bullet-wrapper {
position: absolute;
left: 33px;
width: 44px !important;
margin: 6px;
width: 44px;
height: 44px;
.color0 {
width: 30px !important;
margin: 5px;
margin-left: 7px;
height: 30px;
border-radius: 50%;
background-size: 14px;

View File

@@ -20,33 +20,31 @@
-
-->
<template>
<li v-if="boards.length > 0"
:id="id"
<AppNavigationItem v-if="boards.length > 0"
:title="text"
:class="{'open': opened, 'collapsible': collapsible }">
<button v-if="collapsible" class="collapse" @click.prevent.stop="toggleCollapse" />
<a :class="icon" href="#">
{{ text }}
</a>
<ul v-if="boards.length > 0">
<AppNavigationBoard v-for="board in boards" :key="board.id" :board="board" />
</ul>
</li>
:icon="icon"
:to="to"
:allow-collapse="collapsible"
:open="opened">
<AppNavigationBoard v-for="board in boardsSorted" :key="board.id" :board="board" />
</AppNavigationItem>
</template>
<script>
import ClickOutside from 'vue-click-outside'
import AppNavigationBoard from './AppNavigationBoard'
import { AppNavigationItem } from '@nextcloud/vue'
export default {
name: 'AppNavigationBoardCategory',
components: {
AppNavigationItem,
AppNavigationBoard,
},
directives: {
ClickOutside,
},
props: {
to: {
type: String,
default: '',
},
id: {
type: String,
required: true,
@@ -78,22 +76,19 @@ export default {
}
},
computed: {
boardsSorted() {
return [...this.boards].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
collapsible() {
return this.boards.length > 0
},
},
watch: {
boards: function(newVal, prevVal) {
boards(newVal, prevVal) {
if (this.openOnAddBoards === true && prevVal.length < newVal.length) {
this.opened = true
}
},
},
mounted() {},
methods: {
toggleCollapse() {
this.opened = !this.opened
},
},
}
</script>