Files
deck/src/components/board/Stack.vue
Julius Härtl a9b65de341 fix: Move card create input to the bottom
Signed-off-by: Julius Härtl <jus@bitgrid.net>
2024-09-02 08:09:27 +02:00

495 lines
12 KiB
Vue

<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="stack" :data-cy-stack="stack.title">
<div v-click-outside="stopCardCreation"
class="stack__header"
:class="{'stack__header--add': showAddCard}"
:aria-label="stack.title">
<transition name="fade" mode="out-in">
<h3 v-if="!canManage || isArchived" tabindex="0">
{{ stack.title }}
</h3>
<h3 v-else-if="!editing"
title="stack.title"
dir="auto"
tabindex="0"
:aria-label="stack.title"
class="stack__title"
@click="startEditing(stack)"
@keydown.enter="startEditing(stack)">
{{ stack.title }}
</h3>
<form v-else-if="editing"
v-click-outside="cancelEdit"
data-cy="editStackTitleForm"
@submit.prevent="finishedEdit(stack)"
@keyup.esc="cancelEdit">
<input v-model="copiedStack.title"
v-focus
dir="auto"
type="text"
required="required">
<input title="t('deck', 'Edit list title')"
class="icon-confirm"
type="submit"
value="">
</form>
</transition>
<NcActions v-if="canManage && !isArchived" :force-menu="true">
<NcActionButton v-if="!showArchived" icon="icon-archive" @click="modalArchivAllCardsShow=true">
<template #icon>
<ArchiveIcon decorative />
</template>
{{ t('deck', 'Archive all cards') }}
</NcActionButton>
<NcActionButton v-if="showArchived" @click="modalArchivAllCardsShow=true">
<template #icon>
<ArchiveIcon decorative />
</template>
{{ t('deck', 'Unarchive all cards') }}
</NcActionButton>
<NcActionButton icon="icon-delete" @click="deleteStack(stack)">
{{ t('deck', 'Delete list') }}
</NcActionButton>
</NcActions>
<NcActions v-if="canEdit && !showArchived && !isArchived">
<NcActionButton data-cy="action:add-card" @click.stop="showAddCard=true">
{{ t('deck', 'Add card') }}
<template #icon>
<CardPlusOutline :size="20" />
</template>
</NcActionButton>
</NcActions>
</div>
<NcModal v-if="modalArchivAllCardsShow" @close="modalArchivAllCardsShow=false">
<div class="modal__content">
<h3 v-if="!showArchived">
{{ t('deck', 'Archive all cards in this list') }}
</h3>
<h3 v-else>
{{ t('deck', 'Unarchive all cards in this list') }}
</h3>
<progress :value="stackTransfer.current" :max="stackTransfer.total" />
<button v-if="!showArchived" class="primary" @click="setArchivedToAllCardsFromStack(stack, !showArchived)">
{{ t('deck', 'Archive all cards') }}
</button>
<button v-else class="primary" @click="setArchivedToAllCardsFromStack(stack, !showArchived)">
{{ t('deck', 'Unarchive all cards') }}
</button>
<button @click="modalArchivAllCardsShow=false">
{{ t('deck', 'Cancel') }}
</button>
</div>
</NcModal>
<Container :get-child-payload="payloadForCard(stack.id)"
group-name="stack"
data-click-closes-sidebar="true"
non-drag-area-selector=".dragDisabled"
:drag-handle-selector="dragHandleSelector"
data-dragscroll-enabled
@should-accept-drop="canEdit"
@drag-start="draggingCard = true"
@drag-end="draggingCard = false"
@drop="($event) => onDropCard(stack.id, $event)">
<Draggable v-for="card in cardsByStack" :key="card.id">
<transition :appear="animate && !card.animated && (card.animated=true)"
:appear-class="'zoom-appear-class'"
:appear-active-class="'zoom-appear-active-class'">
<CardItem :id="card.id" ref="card" :dragging="draggingCard" />
</transition>
</Draggable>
</Container>
<transition name="slide-bottom" appear>
<div v-show="showAddCard" class="stack__card-add">
<form :class="{ 'icon-loading-small': stateCardCreating }"
@submit.prevent.stop="clickAddCard()">
<label for="new-stack-input-main" class="hidden-visually">{{ t('deck', 'Add a new card') }}</label>
<input id="new-stack-input-main"
ref="newCardInput"
v-model="newCardTitle"
type="text"
class="no-close"
:disabled="stateCardCreating"
:placeholder="t('deck', 'Card name')"
required
pattern=".*\S+.*"
@focus="onCreateCardFocus"
@keydown.esc="stopCardCreation">
<input v-show="!stateCardCreating"
class="icon-confirm"
type="submit"
value="">
</form>
</div>
</transition>
</div>
</template>
<script>
import ClickOutside from 'vue-click-outside'
import { mapGetters, mapState } from 'vuex'
import { Container, Draggable } from 'vue-smooth-dnd'
import ArchiveIcon from 'vue-material-design-icons/Archive.vue'
import CardPlusOutline from 'vue-material-design-icons/CardPlusOutline.vue'
import { NcActions, NcActionButton, NcModal } from '@nextcloud/vue'
import { showError, showUndo } from '@nextcloud/dialogs'
import CardItem from '../cards/CardItem.vue'
import '@nextcloud/dialogs/style.css'
export default {
name: 'Stack',
components: {
NcActions,
NcActionButton,
CardItem,
Container,
Draggable,
NcModal,
ArchiveIcon,
CardPlusOutline,
},
directives: {
ClickOutside,
},
props: {
dragging: {
type: Boolean,
default: false,
},
stack: {
type: Object,
default: undefined,
},
},
data() {
return {
editing: false,
draggingCard: false,
copiedStack: '',
newCardTitle: '',
showAddCard: false,
stateCardCreating: false,
animate: false,
modalArchivAllCardsShow: false,
stackTransfer: {
total: 0,
current: null,
},
}
},
computed: {
...mapGetters([
'canManage',
'canEdit',
'isArchived',
]),
...mapState({
showArchived: state => state.showArchived,
}),
cardsByStack() {
return this.$store.getters.cardsByStack(this.stack.id).filter((card) => {
if (this.showArchived) {
return card.archived
}
return !card.archived
})
},
dragHandleSelector() {
return this.canEdit && !this.showArchived ? null : '.no-drag'
},
cardDetailsInModal: {
get() {
return this.$store.getters.config('cardDetailsInModal')
},
set(newValue) {
this.$store.dispatch('setConfig', { cardDetailsInModal: newValue })
},
},
},
watch: {
showAddCard(newValue) {
if (!newValue) {
this.$store.dispatch('toggleShortcutLock', false)
} else {
this.$nextTick(() => {
this.$refs.newCardInput.focus()
})
}
},
},
methods: {
stopCardCreation(e) {
// For some reason the submit event triggers a MouseEvent that is bubbling to the outside
// so we have to ignore it
e.stopPropagation()
if (this.$refs.newCardInput && this.$refs.newCardInput.parentElement === e.target.parentElement) {
return false
}
this.showAddCard = false
return false
},
async onDropCard(stackId, event) {
const { addedIndex, removedIndex, payload } = event
const card = Object.assign({}, payload)
if (this.stack.id === stackId) {
if (addedIndex !== null && removedIndex === null) {
// move card to new stack
card.stackId = stackId
card.order = addedIndex
console.debug('move card to stack', card.stackId, card.order)
await this.$store.dispatch('reorderCard', card)
}
if (addedIndex !== null && removedIndex !== null) {
card.order = addedIndex
console.debug('move card in stack', card.stackId, card.order)
await this.$store.dispatch('reorderCard', card)
}
}
},
payloadForCard(stackId) {
return index => {
return this.cardsByStack[index]
}
},
deleteStack(stack) {
this.$store.dispatch('deleteStack', stack)
showUndo(t('deck', 'List deleted'), () => this.$store.dispatch('stackUndoDelete', stack))
},
setArchivedToAllCardsFromStack(stack, isArchived) {
this.stackTransfer.total = this.cardsByStack.length
this.cardsByStack.forEach((card, index) => {
this.stackTransfer.current = index
this.$store.dispatch('archiveUnarchiveCard', { ...card, archived: isArchived })
})
this.modalArchivAllCardsShow = false
},
startEditing(stack) {
if (this.dragging) {
return
}
this.copiedStack = Object.assign({}, stack)
this.editing = true
},
finishedEdit(stack) {
if (this.copiedStack.title !== stack.title) {
this.$store.dispatch('updateStack', this.copiedStack)
}
this.editing = false
},
cancelEdit() {
this.editing = false
},
async clickAddCard() {
this.stateCardCreating = true
try {
this.animate = true
const newCard = await this.$store.dispatch('addCard', {
title: this.newCardTitle,
stackId: this.stack.id,
boardId: this.stack.boardId,
})
this.newCardTitle = ''
this.showAddCard = true
this.$nextTick(() => {
this.$refs.newCardInput.focus()
this.animate = false
this.$refs.card[(this.$refs.card.length - 1)].scrollIntoView()
})
if (!this.cardDetailsInModal) {
this.$router.push({ name: 'card', params: { cardId: newCard.id } })
}
} catch (e) {
showError('Could not create card: ' + e.response.data.message)
} finally {
this.stateCardCreating = false
}
},
onCreateCardFocus() {
this.$store.dispatch('toggleShortcutLock', true)
},
},
}
</script>
<style lang="scss" scoped>
@use 'sass:math';
@import './../../css/variables';
.stack {
width: $stack-width + $stack-spacing * 3;
}
.stack__header {
display: flex;
position: sticky;
top: 0;
z-index: 100;
padding-left: $card-spacing;
padding-right: $card-spacing;
margin: 6px;
margin-top: 0;
cursor: grab;
background-color: var(--color-main-background);
// Smooth fade out of the cards at the top
&:before {
content: ' ';
display: block;
position: absolute;
width: calc(100% - 16px);
height: 20px;
top: 30px;
left: 0px;
z-index: 99;
transition: top var(--animation-slow);
background-image: linear-gradient(180deg, var(--color-main-background) 3px, rgba(255, 255, 255, 0) 100%);
body.theme--dark & {
background-image: linear-gradient(180deg, var(--color-main-background) 3px, rgba(0, 0, 0, 0) 100%);
}
}
& > * {
position: relative;
z-index: 100;
}
h3, form {
flex-grow: 1;
display: flex;
cursor: inherit;
margin: 0;
input[type=text] {
flex-grow: 1;
}
}
h3.stack__title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc($stack-width - 60px);
border-radius: 3px;
padding: 4px 4px;
font-size: var(--default-font-size);
&:focus-visible {
outline: 2px solid var(--color-border-dark);
border-radius: 3px;
}
}
form {
margin: -4px;
input {
font-weight: bold;
padding: 0 6px;
}
input[type="submit"] {
border-style: solid;
border-left-style: none;
}
}
:deep {
.action-item,
.v-popper--theme-dropdown {
display: flex;
}
}
}
.stack__card-add {
flex-shrink: 0;
z-index: 100;
display: flex;
margin-bottom: 5px;
padding-top: var(--default-grid-baseline);
background-color: var(--color-main-background);
form {
display: flex;
margin-left: $stack-spacing;
margin-right: $stack-spacing;
width: 100%;
border: 2px solid var(--color-border-maxcontrast);
border-radius: var(--border-radius-large);
overflow: hidden;
padding: 2px;
}
&.icon-loading-small:after,
&.icon-loading-small-dark:after {
margin-left: calc(50% - 25px);
}
input[type=text] {
flex-grow: 1;
}
input {
border: none;
margin: 0;
padding: 4px;
}
}
/**
* Rules to handle scrolling behaviour are inherited from Board.vue
*/
.slide-top-enter-active,
.slide-top-leave-active {
transition: all 100ms ease;
}
.slide-top-enter, .slide-top-leave-to {
transform: translateY(-10px);
opacity: 0;
}
.slide-bottom-enter-active,
.slide-bottom-leave-active {
transition: all 100ms ease;
}
.slide-bottom-enter, .slide-bottom-leave-to {
transform: translateY(20px);
opacity: 0;
}
.modal__content {
width: 25vw;
min-width: 250px;
min-height: 100px;
text-align: center;
margin: 20px 20px 20px 20px;
}
.modal__content button {
float: right;
}
progress {
margin-top: 3px;
margin-bottom: 30px;
}
</style>