diff --git a/cypress/e2e/cardColor.js b/cypress/e2e/cardColor.js index 569e43ff19..6c053bbdb1 100644 --- a/cypress/e2e/cardColor.js +++ b/cypress/e2e/cardColor.js @@ -46,7 +46,7 @@ describe('Card color', function () { const newCardTitle = 'Card with color' - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .first().click() cy.get('.stack__card-add form input#new-stack-input-main') .type(newCardTitle) diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js index a62e5bedc3..82d72a7aaa 100644 --- a/cypress/e2e/cardFeatures.js +++ b/cypress/e2e/cardFeatures.js @@ -52,7 +52,7 @@ describe('Card', function () { cy.get('.board .stack').eq(0).within(() => { cy.get('.card:contains("Hello world")').should('be.visible') - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .first().click() cy.get('.stack__card-add form input#new-stack-input-main') @@ -69,7 +69,7 @@ describe('Card', function () { cy.intercept({ method: 'POST', url: '**/ocs/v2.php/apps/deck/api/v1.0/cards' }).as('save') cy.intercept({ method: 'GET', url: '**/apps/deck/boards/*' }).as('getBoard') - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .first().click() // Somehow this avoids the electron crash @@ -98,7 +98,7 @@ describe('Card', function () { cy.visit(`/apps/deck/#/board/${boardId}`) const absoluteUrl = `https://example.com` cy.get('.board .stack').eq(0).within(() => { - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .first().click() cy.get('.stack__card-add form input#new-stack-input-main') @@ -120,7 +120,7 @@ describe('Card', function () { const absoluteUrl = `https://example.com` const plainTitle = 'New title' cy.get('.board .stack').eq(0).within(() => { - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .first().click() cy.get('.stack__card-add form input#new-stack-input-main') @@ -260,7 +260,7 @@ describe('Card', function () { it('Set a due date', function () { const newCardTitle = 'Card with a due date' - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .first().click() cy.get('.stack__card-add form input#new-stack-input-main') .type(newCardTitle) @@ -296,13 +296,20 @@ describe('Card', function () { it('Add a label', function () { const newCardTitle = 'Card with labels' - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .first().click() cy.get('.stack__card-add form input#new-stack-input-main') .type(newCardTitle) cy.get('.stack__card-add form input[type=submit]') .first().click() cy.get(`.card:contains("${newCardTitle}")`).should('be.visible').click() + cy.get('body').then(($body) => { + const addCardInput = $body.find('.stack__card-add form input#new-stack-input-main') + if (addCardInput.length) { + cy.wrap(addCardInput.first()).type('{esc}') + } + }) + cy.get('.stack__card-add form').should('not.exist') // Add delay to ensure the events are bound cy.wait(1000) @@ -316,8 +323,10 @@ describe('Card', function () { cy.get('.vs__selected .tag:contains("Action needed")') .parent().find('button').click() - cy.get(`.card:contains("${newCardTitle}")`).find('.labels li:contains("Later")') - .should('be.visible') + cy.get(`.card:contains("${newCardTitle}")`) + .scrollIntoView({ block: 'center' }) + .find('.labels li:contains("Later")') + .should('exist') cy.get(`.card:contains("${newCardTitle}")`).find('.labels li:contains("Action needed")') .should('not.exist') }) diff --git a/cypress/e2e/sharingFeatures.js b/cypress/e2e/sharingFeatures.js index abe1ed3f8f..d90844c677 100644 --- a/cypress/e2e/sharingFeatures.js +++ b/cypress/e2e/sharingFeatures.js @@ -32,7 +32,7 @@ describe('Board', function() { cy.login(recipient) cy.visit(`/apps/deck/#/board/${boardId}`) cy.get('.board-title').contains(board.title) - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .should('not.exist') }) }) @@ -50,7 +50,7 @@ describe('Board', function() { cy.login(recipient) cy.visit(`/apps/deck/#/board/${boardId}`) cy.get('.board-title').contains(board.title) - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .should('not.exist') }) }) @@ -71,7 +71,7 @@ describe('Board', function() { cy.login(recipient) cy.visit(`/apps/deck/#/board/${boardId}`) cy.get('.board-title').contains(board.title) - cy.get('.button-vue[aria-label*="Add card"]') + cy.get('[data-cy="action:add-card"]') .first().click() }) }) diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index b4afb8fc45..7378b09f41 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -52,7 +52,8 @@ public function getAll(): array { $data = [ 'calendar' => $this->isCalendarEnabled(), 'cardDetailsInModal' => $this->isCardDetailsInModal(), - 'cardIdBadge' => $this->isCardIdBadgeEnabled() + 'cardIdBadge' => $this->isCardIdBadgeEnabled(), + 'stackAddCardAtTop' => $this->isStackAddCardAtTopEnabled() ]; if ($this->groupManager->isAdmin($userId)) { $data['groupLimit'] = $this->get('groupLimit'); @@ -90,6 +91,8 @@ public function get(string $key) { return false; } return (bool)$this->config->getUserValue($this->getUserId(), Application::APP_ID, 'cardIdBadge', false); + case 'stackAddCardAtTop': + return $this->isStackAddCardAtTopEnabled(); } return false; } @@ -134,6 +137,15 @@ public function isCardIdBadgeEnabled(): bool { return (bool)$this->config->getUserValue($userId, Application::APP_ID, 'cardIdBadge', $defaultState); } + public function isStackAddCardAtTopEnabled(): bool { + $userId = $this->getUserId(); + if ($userId === null) { + return false; + } + + return (bool)$this->config->getUserValue($userId, Application::APP_ID, 'stackAddCardAtTop', false); + } + public function ensureFederationEnabled() { if (!$this->get('federationEnabled')) { throw new FederationDisabledException(); @@ -181,6 +193,10 @@ public function set($key, $value) { $this->config->setUserValue($userId, Application::APP_ID, 'cardIdBadge', (string)$value); $result = $value; break; + case 'stackAddCardAtTop': + $this->config->setUserValue($userId, Application::APP_ID, 'stackAddCardAtTop', (string)$value); + $result = $value; + break; case 'board': // extra check that user only send one of the allowed board settings and not something random $parts = explode(':', $key, 3); diff --git a/src/components/Controls.vue b/src/components/Controls.vue index c84b126a0e..5001ab9f99 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -14,7 +14,7 @@ {{ overviewName }} - + {{ t('deck', 'Add card') }} diff --git a/src/components/DeckAppSettings.vue b/src/components/DeckAppSettings.vue index 3935dbff5d..4d095b3448 100644 --- a/src/components/DeckAppSettings.vue +++ b/src/components/DeckAppSettings.vue @@ -12,6 +12,8 @@ + @@ -119,6 +121,14 @@ export default { this.$store.dispatch('setConfig', { cardDetailsInModal: newValue }) }, }, + stackAddCardAtTop: { + get() { + return this.$store.getters.config('stackAddCardAtTop') === true + }, + set(newValue) { + this.$store.dispatch('setConfig', { stackAddCardAtTop: newValue }) + }, + }, cardIdBadge: { get() { return this.$store.getters.config('cardIdBadge') diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue index 3268e9bc3c..92b8db9d30 100644 --- a/src/components/board/Board.vue +++ b/src/components/board/Board.vue @@ -317,16 +317,23 @@ export default { position: relative; .smooth-dnd-container.vertical { - $margin-x: calc($stack-gap * -1); display: flex; flex-direction: column; gap: $stack-gap; padding: $stack-gap; - margin: 0 $margin-x; + margin: 0 calc(#{$stack-gap} * -1); overflow-y: auto; scrollbar-gutter: stable; } + // Keep room for the 'Add card' control that the card list reaches + // into, so that the last card can be scrolled above it + &.stack--add-card-at-bottom .smooth-dnd-container.vertical { + margin-bottom: calc(-1 * var(--stack-card-add-box-height)); + padding-bottom: calc(var(--stack-card-add-box-height) + #{$stack-gap}); + scroll-padding-bottom: calc(var(--stack-card-add-box-height) + #{$stack-gap}); + } + .smooth-dnd-container.vertical > .smooth-dnd-draggable-wrapper { overflow: initial; } diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue index 7f81349f57..049bb64e29 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -4,7 +4,14 @@ --> - + - - - {{ t('deck', 'Add card') }} - - - - - @@ -101,8 +100,42 @@ + + + + + + {{ t('deck', 'Add card') }} + + + {{ t('deck', 'Add a new card') }} + + + + + - - - - {{ t('deck', 'Add a new card') }} - - - - - + + + + + + {{ t('deck', 'Add card') }} + + + {{ t('deck', 'Add a new card') }} + + + + @@ -152,9 +195,9 @@ 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/ArchiveOutline.vue' -import CardPlusOutline from 'vue-material-design-icons/CardPlusOutline.vue' import CheckCircleOutline from 'vue-material-design-icons/CheckCircleOutline.vue' -import { NcActions, NcActionButton, NcModal } from '@nextcloud/vue' +import PlusIcon from 'vue-material-design-icons/Plus.vue' +import { NcActions, NcActionButton, NcButton, NcModal } from '@nextcloud/vue' import { showError, showUndo } from '@nextcloud/dialogs' import CardItem from '../cards/CardItem.vue' @@ -168,13 +211,14 @@ export default { components: { NcActions, NcActionButton, + NcButton, CardItem, Container, Draggable, NcModal, ArchiveIcon, - CardPlusOutline, CheckCircleOutline, + PlusIcon, }, directives: { ClickOutside, @@ -236,6 +280,15 @@ export default { this.$store.dispatch('setConfig', { cardDetailsInModal: newValue }) }, }, + stackAddCardAtTop() { + return this.$store.getters.config('stackAddCardAtTop') === true + }, + canAddCardAtTop() { + return this.canEdit && !this.showArchived && !this.isArchived && this.stackAddCardAtTop + }, + canAddCardAtBottom() { + return this.canEdit && !this.showArchived && !this.isArchived && !this.stackAddCardAtTop + }, }, watch: { showAddCard(newValue) { @@ -255,6 +308,10 @@ export default { methods: { ...mapActions(useTrashbinStore, ['stackUndoDelete']), + closeCardCreation() { + this.showAddCard = false + return false + }, stopCardCreation(e) { // For some reason the submit event triggers a MouseEvent that is bubbling to the outside // so we have to ignore it @@ -328,18 +385,26 @@ export default { async clickAddCard() { this.stateCardCreating = true try { + const addCardAtTop = this.stackAddCardAtTop this.animate = true const newCard = await this.$store.dispatch('addCard', { title: this.newCardTitle, stackId: this.stack.id, boardId: this.stack.boardId, + // Without an order the API appends the card to the end of the stack + ...(addCardAtTop ? { order: 0 } : {}), }) + if (addCardAtTop) { + // Creating a card does not move the existing cards down, so reorder + await this.$store.dispatch('reorderCard', { ...newCard, order: 0 }) + } this.newCardTitle = '' this.showAddCard = true this.$nextTick(() => { this.$refs.newCardInput.focus() this.animate = false - this.$refs.card[(this.$refs.card.length - 1)].scrollIntoView() + // Refs of a v-for are registered in creation order, not in list order + this.$refs.card?.find((card) => card.id === newCard.id)?.scrollIntoView() }) if (!this.cardDetailsInModal) { this.$router.push({ name: 'card', params: { cardId: newCard.id } }) @@ -394,9 +459,38 @@ export default { @import './../../css/variables.scss'; .stack { + --stack-card-add-control-height: calc(var(--default-clickable-area) + 2 * var(--default-grid-baseline)); + --stack-card-add-box-height: calc(var(--stack-card-add-control-height) + #{$stack-gap}); width: 100%; .dnd-container { - flex-grow: 1; + flex: 1 1 auto; + min-height: 0; + } + + &.stack--add-card-at-bottom { + // The card list fills the column and reaches underneath the add card + // control (see Board.vue), so that cards can be dropped anywhere in + // the free space of the list. + &.stack--dragging-card .stack__card-add--bottom { + // smooth-dnd resolves the hovered container with elementFromPoint(), + // so the control has to let the drag through to the list underneath + // it, otherwise dragging over it would count as leaving the list. + pointer-events: none; + } + } + + &.stack--add-card-at-top { + &:after { + content: ''; + display: block; + position: absolute; + width: 100%; + height: $stack-gap; + bottom: 0; + z-index: 99; + pointer-events: none; + background-image: linear-gradient(0deg, var(--color-main-background) 0%, transparent 100%); + } } &.stack--done-column { @@ -515,10 +609,30 @@ export default { flex-shrink: 0; z-index: 100; display: flex; - padding-bottom: $stack-gap; background-color: var(--color-main-background); position: relative; + &--top { + padding-top: $stack-gap; + + &:after { + content: ''; + display: block; + position: absolute; + width: 100%; + height: $stack-gap; + bottom: 0; + z-index: 99; + pointer-events: none; + background-image: linear-gradient(180deg, var(--color-main-background) 0%, transparent 100%); + transform: translateY(100%); + } + } + + &--bottom { + padding-bottom: $stack-gap; + } + // Smooth fade out of the cards at the top &:before { content: ''; @@ -532,9 +646,21 @@ export default { transform: translateY(-100%); } + :deep(.stack__card-add-button.button-vue) { + --button-size: var(--stack-card-add-control-height); + color: var(--color-text-maxcontrast); + + &:hover:not(:disabled), + &:focus-visible { + color: var(--color-main-text); + } + } + form { display: flex; width: 100%; + height: var(--stack-card-add-control-height); + box-sizing: border-box; border: 2px solid var(--color-border-maxcontrast); border-radius: var(--border-radius-large); overflow: hidden; @@ -557,30 +683,6 @@ export default { } } - /** - * 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;