diff --git a/cypress/e2e/cardColor.js b/cypress/e2e/cardColor.js index 569e43ff1..6c053bbdb 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 c98b776a8..1e734a083 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 abe1ed3f8..d90844c67 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 b4afb8fc4..7378b09f4 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 6b8163fd2..6f98496c4 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 3935dbff5..4d095b344 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 0af01efd9..be1579d7e 100644 --- a/src/components/board/Board.vue +++ b/src/components/board/Board.vue @@ -321,16 +321,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 9936f556b..5335ae002 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -4,10 +4,16 @@ --> - - + @@ -69,14 +75,6 @@ {{ t('deck', 'Delete list') }} - - - {{ t('deck', 'Add card') }} - - - - - @@ -101,8 +99,14 @@ + + - - - - {{ t('deck', 'Add a new card') }} - - - - - + @@ -152,12 +137,12 @@ 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 { showError, showUndo } from '@nextcloud/dialogs' +import { showUndo } from '@nextcloud/dialogs' import CardItem from '../cards/CardItem.vue' +import StackCardAdd from './StackCardAdd.vue' import '@nextcloud/dialogs/style.css' import { mapActions } from 'pinia' @@ -170,11 +155,11 @@ export default { NcActions, NcActionButton, CardItem, + StackCardAdd, Container, Draggable, NcModal, ArchiveIcon, - CardPlusOutline, CheckCircleOutline, }, directives: { @@ -195,9 +180,6 @@ export default { editing: false, draggingCard: false, copiedStack: '', - newCardTitle: '', - showAddCard: false, - stateCardCreating: false, animate: false, modalArchivAllCardsShow: false, stackTransfer: { @@ -229,27 +211,13 @@ export default { 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 }) - }, + stackAddCardAtTop() { + return this.$store.getters.config('stackAddCardAtTop') === true }, - }, - watch: { - showAddCard(newValue) { - if (!newValue) { - this.$store.dispatch('toggleShortcutLock', false) - } else { - this.$nextTick(() => { - this.$refs.newCardInput.focus() - }) - } + canAddCard() { + return this.canEdit && !this.showArchived && !this.isArchived }, }, - mounted() { this.setupAutoscrollOnDrag() }, @@ -257,16 +225,6 @@ export default { methods: { ...mapActions(useTrashbinStore, ['stackUndoDelete']), ...mapActions(useStackStore, ['setDoneStack', 'deleteStack', 'updateStack']), - 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) @@ -327,33 +285,12 @@ export default { 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) + handleCardCreated(newCard) { + this.$nextTick(() => { + this.animate = false + // Refs of a v-for are registered in creation order, not in list order + this.$refs.card?.find((card) => card.id === newCard.id)?.scrollIntoView() + }) }, setupAutoscrollOnDrag() { let timer @@ -396,9 +333,33 @@ 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.stack--dragging-card .stack__card-add { + // The card list reaches underneath the control (see Board.vue) and + // smooth-dnd resolves the hovered container with elementFromPoint(), + // so the control must not swallow the drag. + 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 { @@ -513,76 +474,6 @@ export default { } } - .stack__card-add { - flex-shrink: 0; - z-index: 100; - display: flex; - padding-bottom: $stack-gap; - background-color: var(--color-main-background); - position: relative; - - // Smooth fade out of the cards at the top - &:before { - content: ''; - display: block; - position: absolute; - width: 100%; - height: $stack-gap; - z-index: 99; - transition: bottom var(--animation-slow); - background-image: linear-gradient(0deg, var(--color-main-background) 0%, transparent 100%); - transform: translateY(-100%); - } - - form { - display: flex; - 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-inline-start: calc(50% - 25px); - } - - input[type=text] { - flex-grow: 1; - padding-inline-end: 16px; - } - - input { - border: none; - margin: 0; - } - } - - /** - * 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; diff --git a/src/components/board/StackCardAdd.vue b/src/components/board/StackCardAdd.vue new file mode 100644 index 000000000..b235cd036 --- /dev/null +++ b/src/components/board/StackCardAdd.vue @@ -0,0 +1,205 @@ + + + + + + + + + {{ t('deck', 'Add card') }} + + + {{ t('deck', 'Add a new card') }} + + + + + + + + +