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 c98b776a88..3a741858d8 100644 --- a/cypress/e2e/cardFeatures.js +++ b/cypress/e2e/cardFeatures.js @@ -25,6 +25,17 @@ const useModal = (useModal) => { }) } +const addCardsAtTop = (enabled) => { + return cy.request({ + method: 'POST', + url: `${Cypress.env('baseUrl')}/ocs/v2.php/apps/deck/api/v1.0/config/stackAddCardAtTop?format=json`, + auth, + body: { value: enabled }, + }).then((response) => { + expect(response.status).to.eq(200) + }) +} + describe('Card', function () { let boardId before(function () { @@ -52,7 +63,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') @@ -63,13 +74,78 @@ describe('Card', function () { }) }) + describe('New card position', function() { + afterEach(function() { + addCardsAtTop(false) + }) + + it('Adds new cards to the configured side of the list', function() { + const bottomCardTitle = 'Card added at bottom' + const firstTopCardTitle = 'First card added at top' + const secondTopCardTitle = 'Second card added at top' + const finalBottomCardTitle = 'Card added at bottom again' + + addCardsAtTop(false) + cy.intercept({ method: 'POST', url: '**/ocs/v2.php/apps/deck/api/v1.0/cards' }).as('createCard') + cy.intercept({ method: 'PUT', url: '**/ocs/v2.php/apps/deck/api/v1.0/cards/*/reorder' }).as('reorderCard') + cy.intercept({ method: 'POST', url: '**/ocs/v2.php/apps/deck/api/v1.0/config/stackAddCardAtTop' }).as('setCardPosition') + cy.visit(`/apps/deck/#/board/${boardId}`) + + cy.get('.board .stack').eq(0).within(() => { + cy.get('[data-cy="action:add-card"]').click() + cy.get('.stack__card-add input[type="text"]').type(bottomCardTitle) + cy.get('.stack__card-add input[type="submit"]').click() + cy.wait('@createCard') + cy.get('.card').last().should('contain', bottomCardTitle) + }) + + cy.get('[data-cy="navigation:settings"]').click() + cy.get('[data-cy="setting:add-card-at-top"] input[role="switch"]').check({ force: true }) + cy.wait('@setCardPosition') + cy.visit(`/apps/deck/#/board/${boardId}`) + + for (const title of [firstTopCardTitle, secondTopCardTitle]) { + cy.get('.board .stack').eq(0).within(() => { + cy.get('[data-cy="action:add-card"]').click() + cy.get('.stack__card-add input[type="text"]').type(title) + cy.get('.stack__card-add input[type="submit"]').click() + cy.wait('@createCard') + }) + } + + cy.get('.board .stack').eq(0).within(() => { + cy.get('.card').eq(0).should('contain', secondTopCardTitle) + cy.get('.card').eq(1).should('contain', firstTopCardTitle) + }) + cy.get('@reorderCard.all').should('have.length', 0) + + cy.reload() + cy.get('.board .stack').eq(0).within(() => { + cy.get('.card').eq(0).should('contain', secondTopCardTitle) + cy.get('.card').eq(1).should('contain', firstTopCardTitle) + }) + + cy.get('[data-cy="navigation:settings"]').click() + cy.get('[data-cy="setting:add-card-at-top"] input[role="switch"]').uncheck({ force: true }) + cy.wait('@setCardPosition') + cy.visit(`/apps/deck/#/board/${boardId}`) + cy.get('.board .stack').eq(0).within(() => { + cy.get('[data-cy="action:add-card"]').click() + cy.get('.stack__card-add input[type="text"]').type(finalBottomCardTitle) + cy.get('.stack__card-add input[type="submit"]').click() + cy.wait('@createCard') + cy.get('.card').last().should('contain', finalBottomCardTitle) + }) + }) + }) + it('Create card from overview', function () { cy.visit(`/apps/deck/#/`) const newCardTitle = 'Test create from overview' 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 +174,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 +196,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 +336,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,7 +372,7 @@ 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) @@ -316,7 +392,9 @@ 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")') + cy.get(`.card:contains("${newCardTitle}")`) + .scrollIntoView({ block: 'center' }) + .find('.labels li:contains("Later")') .should('be.visible') 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/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php index cd6629d204..1e33f1d9b9 100644 --- a/lib/Controller/CardOcsController.php +++ b/lib/Controller/CardOcsController.php @@ -21,6 +21,12 @@ use OCP\IRequest; class CardOcsController extends OCSController { + /** + * Sentinel order used when the client does not request a specific position. + * Cards created with this order are appended to the end of the stack. + */ + private const DEFAULT_ORDER = 999; + public function __construct( string $appName, IRequest $request, @@ -36,7 +42,7 @@ public function __construct( #[NoAdminRequired] #[PublicPage] - public function create(string $title, int $stackId, ?int $boardId = null, ?string $type = 'plain', ?string $owner = null, ?int $order = 999, ?string $description = '', $duedate = null, $startdate = null, ?array $labels = [], ?array $users = [], ?string $color = null) { + public function create(string $title, int $stackId, ?int $boardId = null, ?string $type = 'plain', ?string $owner = null, ?int $order = self::DEFAULT_ORDER, ?string $description = '', $duedate = null, $startdate = null, ?array $labels = [], ?array $users = [], ?string $color = null) { if ($boardId) { $board = $this->boardService->find($boardId, false); if ($board->getExternalId()) { @@ -48,7 +54,8 @@ public function create(string $title, int $stackId, ?int $boardId = null, ?strin if (!$owner) { $owner = $this->userId; } - $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate, $startdate, $color); + // An explicit order means the client wants the card at that position, so shift the surrounding cards + $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate, $startdate, $color, insertAtPosition: $order !== self::DEFAULT_ORDER); // foreach ($labels as $label) { // $this->assignLabel($card->getId(), $label); diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 325dc95777..7ebc8d8a6f 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -30,6 +30,7 @@ use OCP\Collaboration\Reference\IReferenceManager; use OCP\Comments\ICommentsManager; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserManager; @@ -58,6 +59,7 @@ public function __construct( private CardServiceValidator $cardServiceValidator, private AssignmentService $assignmentService, private IReferenceManager $referenceManager, + private IDBConnection $connection, private ?string $userId, ) { } @@ -187,7 +189,7 @@ public function findCalendarEntries(int $boardId): array { * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadrequestException */ - public function create(string $title, int $stackId, string $type, int $order, string $owner, string $description = '', $duedate = null, $startdate = null, ?string $color = null): Card { + public function create(string $title, int $stackId, string $type, int $order, string $owner, string $description = '', $duedate = null, $startdate = null, ?string $color = null, bool $insertAtPosition = false): Card { $this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner')); $this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_EDIT); @@ -204,7 +206,21 @@ public function create(string $title, int $stackId, string $type, int $order, st $card->setDuedate($duedate); $card->setStartdate($startdate); $card->setColor($color); - $card = $this->cardMapper->insert($card); + + if (!$insertAtPosition) { + $card = $this->cardMapper->insert($card); + } else { + // Insert at the requested position and shift the surrounding cards + $this->connection->beginTransaction(); + try { + $card = $this->cardMapper->insert($card); + $this->reorderCards($card->getId(), $stackId, $order); + $this->connection->commit(); + } catch (\Throwable $e) { + $this->connection->rollBack(); + throw $e; + } + } $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_CREATE, [], $card->getOwner()); $this->changeHelper->cardChanged($card->getId(), false); @@ -467,6 +483,17 @@ public function reorder(int $id, int $stackId, int $order): array { $changes->setAfter($card); $this->activityManager->triggerUpdateEvents(ActivityManager::DECK_OBJECT_CARD, $changes, ActivityManager::SUBJECT_CARD_UPDATE); + $result = $this->reorderCards($id, $stackId, $order); + $this->changeHelper->cardChanged($id, false); + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $changes->getBefore())); + + return $result; + } + + /** + * @return list + */ + private function reorderCards(int $id, int $stackId, int $order): array { $cardsToReorder = $this->cardMapper->findAll($stackId); $result = []; $i = 0; @@ -489,8 +516,6 @@ public function reorder(int $id, int $stackId, int $order): array { $this->cardMapper->update($cardToReorder); $result[$cardToReorder->getOrder()] = $cardToReorder; } - $this->changeHelper->cardChanged($id, false); - $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $changes->getBefore())); return array_values($result); } diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 1dfa60d4ef..5a59890527 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'); @@ -134,6 +135,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 +191,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 81c39cfc74..b4be8aebb1 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..4973f61dc8 100644 --- a/src/components/DeckAppSettings.vue +++ b/src/components/DeckAppSettings.vue @@ -12,6 +12,9 @@ + @@ -119,6 +122,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 0a76e9e191..5b9cfe3f35 100644 --- a/src/components/board/Board.vue +++ b/src/components/board/Board.vue @@ -332,12 +332,11 @@ 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; } diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue index 317e767752..21764e0b3b 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -4,10 +4,15 @@ --> @@ -153,12 +137,12 @@ import { mapGetters, mapState as mapStateVuex } from 'vuex' import { mapState, mapActions } from 'pinia' 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 { useTrashbinStore } from '../../stores/trashbin.js' @@ -171,11 +155,11 @@ export default { NcActions, NcActionButton, CardItem, + StackCardAdd, Container, Draggable, NcModal, ArchiveIcon, - CardPlusOutline, CheckCircleOutline, }, directives: { @@ -196,9 +180,6 @@ export default { editing: false, draggingCard: false, copiedStack: '', - newCardTitle: '', - showAddCard: false, - stateCardCreating: false, animate: false, modalArchivAllCardsShow: false, stackTransfer: { @@ -233,27 +214,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() }, @@ -264,18 +231,7 @@ export default { ...mapActions(useCardStore, { reorderCardInStore: 'reorderCard', archiveUnarchiveCardInStore: 'archiveUnarchiveCard', - addCardInStore: 'addCard', }), - 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) @@ -335,33 +291,12 @@ export default { cancelEdit() { this.editing = false }, - async clickAddCard() { - this.stateCardCreating = true - try { - this.animate = true - const newCard = await this.addCardInStore({ - 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 @@ -404,9 +339,25 @@ export default { @import './../../css/variables.scss'; .stack { + --stack-card-add-control-height: calc(var(--default-clickable-area) + 2 * var(--default-grid-baseline)); width: 100%; .dnd-container { - flex-grow: 1; + flex: 1 1 auto; + min-height: 0; + } + + &.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 { @@ -521,76 +472,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 0000000000..5751092a81 --- /dev/null +++ b/src/components/board/StackCardAdd.vue @@ -0,0 +1,212 @@ + + + + + + + diff --git a/src/components/navigation/AppNavigation.vue b/src/components/navigation/AppNavigation.vue index e0cffe2387..73a0b6bfdb 100644 --- a/src/components/navigation/AppNavigation.vue +++ b/src/components/navigation/AppNavigation.vue @@ -53,6 +53,7 @@