+
+ @keydown.esc.stop="showAddCard = false">
Date: Thu, 30 Jul 2026 22:44:47 +0000
Subject: [PATCH 24/34] Extract add card form to keep tab order intact
Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com>
---
src/components/board/Stack.vue | 221 +++-----------------------
src/components/board/StackCardAdd.vue | 205 ++++++++++++++++++++++++
2 files changed, 227 insertions(+), 199 deletions(-)
create mode 100644 src/components/board/StackCardAdd.vue
diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue
index c18920a8ff..5335ae0021 100644
--- a/src/components/board/Stack.vue
+++ b/src/components/board/Stack.vue
@@ -12,9 +12,8 @@
'stack--dragging-card': draggingCard,
}"
:data-cy-stack="stack.title">
-
+
+
-
-
-
-
-
- {{ t('deck', 'Add card') }}
-
-
-
+
@@ -162,11 +138,11 @@ import { mapGetters, mapState } from 'vuex'
import { Container, Draggable } from 'vue-smooth-dnd'
import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue'
import CheckCircleOutline from 'vue-material-design-icons/CheckCircleOutline.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 { NcActions, NcActionButton, NcModal } from '@nextcloud/vue'
+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'
@@ -178,14 +154,13 @@ export default {
components: {
NcActions,
NcActionButton,
- NcButton,
CardItem,
+ StackCardAdd,
Container,
Draggable,
NcModal,
ArchiveIcon,
CheckCircleOutline,
- PlusIcon,
},
directives: {
ClickOutside,
@@ -205,9 +180,6 @@ export default {
editing: false,
draggingCard: false,
copiedStack: '',
- newCardTitle: '',
- showAddCard: false,
- stateCardCreating: false,
animate: false,
modalArchivAllCardsShow: false,
stackTransfer: {
@@ -239,14 +211,6 @@ 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
},
@@ -254,18 +218,6 @@ export default {
return this.canEdit && !this.showArchived && !this.isArchived
},
},
- watch: {
- showAddCard(newValue) {
- if (!newValue) {
- this.$store.dispatch('toggleShortcutLock', false)
- } else {
- this.$nextTick(() => {
- this.$refs.newCardInput.focus()
- })
- }
- },
- },
-
mounted() {
this.setupAutoscrollOnDrag()
},
@@ -273,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)
@@ -343,41 +285,12 @@ export default {
cancelEdit() {
this.editing = false
},
- 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
- // 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 } })
- }
- } 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
@@ -436,18 +349,6 @@ export default {
}
&.stack--add-card-at-top {
- .stack__header {
- order: 1;
- }
-
- .stack__card-add {
- order: 2;
- }
-
- .stack__cards-list {
- order: 3;
- }
-
&:after {
content: '';
display: block;
@@ -573,84 +474,6 @@ export default {
}
}
- .stack__card-add {
- flex-shrink: 0;
- z-index: 100;
- display: flex;
- background-color: var(--color-main-background);
- position: relative;
-
- .stack--add-card-at-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%);
- }
- }
-
- .stack--add-card-at-bottom & {
- padding-bottom: $stack-gap;
- }
-
- // 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%);
- }
-
- :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;
- 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;
- }
- }
-
.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..b235cd036e
--- /dev/null
+++ b/src/components/board/StackCardAdd.vue
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
+
+ {{ t('deck', 'Add card') }}
+
+
+
+
+
+
+
+
From 7e606fa7c4a66e61f65cd1c85074f53df2b8090a Mon Sep 17 00:00:00 2001
From: Theo <36564257+theoholl@users.noreply.github.com>
Date: Sat, 8 Aug 2026 18:33:42 +0000
Subject: [PATCH 25/34] Fix card insertion position
Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com>
---
cypress/e2e/cardFeatures.js | 77 +++++++++++++++++++
lib/Controller/CardOcsController.php | 2 +-
lib/Service/CardService.php | 32 +++++++-
src/components/DeckAppSettings.vue | 1 +
src/components/board/StackCardAdd.vue | 12 +--
src/components/navigation/AppNavigation.vue | 1 +
src/stores/card.js | 16 ++--
tests/integration/features/api/config.feature | 16 ++++
.../Service/BatchQueryPerformanceTest.php | 2 +
tests/unit/Service/CardServiceTest.php | 70 +++++++++++++++++
10 files changed, 213 insertions(+), 16 deletions(-)
diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js
index 1e734a0838..60ad9b916f 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 () {
@@ -63,6 +74,72 @@ 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('.stack__card-add input[type="text"]').type('{esc}')
+ })
+ }
+
+ 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'
diff --git a/lib/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php
index cd6629d204..8049fd4f2a 100644
--- a/lib/Controller/CardOcsController.php
+++ b/lib/Controller/CardOcsController.php
@@ -48,7 +48,7 @@ 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);
+ $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate, $startdate, $color, insertAtPosition: $order !== 999);
// foreach ($labels as $label) {
// $this->assignLabel($card->getId(), $label);
diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php
index 325dc95777..d051d0205c 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,20 @@ 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 {
+ $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 +482,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 +515,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/src/components/DeckAppSettings.vue b/src/components/DeckAppSettings.vue
index 4d095b3448..4973f61dc8 100644
--- a/src/components/DeckAppSettings.vue
+++ b/src/components/DeckAppSettings.vue
@@ -13,6 +13,7 @@
diff --git a/src/components/board/StackCardAdd.vue b/src/components/board/StackCardAdd.vue
index b235cd036e..c4b8367fbc 100644
--- a/src/components/board/StackCardAdd.vue
+++ b/src/components/board/StackCardAdd.vue
@@ -44,6 +44,9 @@ import ClickOutside from 'vue-click-outside'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import { NcButton } from '@nextcloud/vue'
import { showError } from '@nextcloud/dialogs'
+import { mapActions } from 'pinia'
+
+import { useCardStore } from '../../stores/card.js'
export default {
name: 'StackCardAdd',
@@ -87,6 +90,9 @@ export default {
},
},
methods: {
+ ...mapActions(useCardStore, {
+ addCardInStore: 'addCard',
+ }),
close() {
this.visible = false
},
@@ -94,17 +100,13 @@ export default {
this.creating = true
this.$emit('creating')
try {
- const newCard = await this.$store.dispatch('addCard', {
+ const newCard = await this.addCardInStore({
title: this.title,
stackId: this.stack.id,
boardId: this.stack.boardId,
// Without an order the API appends the card to the end of the stack
...(this.addAtTop ? { order: 0 } : {}),
})
- if (this.addAtTop) {
- // Creating a card does not move the existing cards down, so reorder
- await this.$store.dispatch('reorderCard', { ...newCard, order: 0 })
- }
this.title = ''
this.$emit('created', newCard)
if (!this.cardDetailsInModal) {
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 @@
diff --git a/src/stores/card.js b/src/stores/card.js
index 774e53a4a9..08f10ad575 100644
--- a/src/stores/card.js
+++ b/src/stores/card.js
@@ -280,6 +280,13 @@ export const useCardStore = defineStore('card', {
},
async addCard(card) {
const createdCard = await apiClient.addCard(card)
+ if (card.order !== undefined) {
+ for (const existingCard of this.cards) {
+ if (existingCard.stackId === createdCard.stackId && existingCard.order >= card.order) {
+ Vue.set(existingCard, 'order', existingCard.order + 1)
+ }
+ }
+ }
this.addCardToStore(createdCard)
return createdCard
},
@@ -302,9 +309,6 @@ export const useCardStore = defineStore('card', {
let i = 0
const newCards = []
for (const c of this.cardsByStack(card.stackId)) {
- if (c.id === card.id) {
- newCards.push(card)
- }
if (i === card.order) {
i++
}
@@ -316,9 +320,9 @@ export const useCardStore = defineStore('card', {
this.updateCardsReorder(newCards)
const stack = useStackStore().stackById(card.stackId)
- apiClient.reorderCard(card, stack.boardId).then((cards) => {
- this.updateCardsReorder(Object.values(cards))
- })
+ const cards = await apiClient.reorderCard(card, stack.boardId)
+ this.updateCardsReorder(Object.values(cards))
+ return cards
},
async archiveUnarchiveCard(card) {
let call = 'archiveCard'
diff --git a/tests/integration/features/api/config.feature b/tests/integration/features/api/config.feature
index e8a39b8cce..c9e3ba7491 100644
--- a/tests/integration/features/api/config.feature
+++ b/tests/integration/features/api/config.feature
@@ -15,6 +15,8 @@ Feature: OCS API - Config
And the response should contain the key "ocs.data.calendar"
And the response should contain the key "ocs.data.cardDetailsInModal"
And the response should contain the key "ocs.data.cardIdBadge"
+ And the response should contain the key "ocs.data.stackAddCardAtTop"
+ And the response value "ocs.data.stackAddCardAtTop" should be "false"
Scenario: GET /api/v1.0/config - The group limit is only exposed to administrators
When sending "GET" to the OCS API endpoint "/config"
@@ -39,6 +41,20 @@ Feature: OCS API - Config
When sending "GET" to the OCS API endpoint "/config"
Then the response value "ocs.data.calendar" should be "true"
+ Scenario: POST /api/v1.0/config/{key} - Set the new card position
+ When sending "POST" to the OCS API endpoint "/config/stackAddCardAtTop" with body:
+ | value | true |
+ Then the response should have a status code "200"
+ And the response value "ocs.data" should be "true"
+ When sending "GET" to the OCS API endpoint "/config"
+ Then the response value "ocs.data.stackAddCardAtTop" should be "true"
+ When sending "POST" to the OCS API endpoint "/config/stackAddCardAtTop" with body:
+ | value | false |
+ Then the response should have a status code "200"
+ And the response value "ocs.data" should be "false"
+ When sending "GET" to the OCS API endpoint "/config"
+ Then the response value "ocs.data.stackAddCardAtTop" should be "false"
+
Scenario: POST /api/v1.0/config/{key} - Set a board config value
Given sending "POST" to the API endpoint "/boards" with body:
| title | Config board |
diff --git a/tests/unit/Service/BatchQueryPerformanceTest.php b/tests/unit/Service/BatchQueryPerformanceTest.php
index 08239a9bc3..76873614f8 100644
--- a/tests/unit/Service/BatchQueryPerformanceTest.php
+++ b/tests/unit/Service/BatchQueryPerformanceTest.php
@@ -42,6 +42,7 @@
use OCP\Collaboration\Reference\IReferenceManager;
use OCP\Comments\ICommentsManager;
use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IDBConnection;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IURLGenerator;
@@ -380,6 +381,7 @@ private function setUpCardService(): void {
$this->createMock(CardServiceValidator::class),
$this->createMock(AssignmentService::class),
$this->referenceManager,
+ $this->createMock(IDBConnection::class),
'user1',
);
}
diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php
index 58084a1fa4..94a96a7a98 100644
--- a/tests/unit/Service/CardServiceTest.php
+++ b/tests/unit/Service/CardServiceTest.php
@@ -45,6 +45,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\IUser;
@@ -97,6 +98,8 @@ class CardServiceTest extends TestCase {
private $cardServiceValidator;
/** @var IReferenceManager|MockObject */
private $referenceManager;
+ /** @var IDBConnection|MockObject */
+ private $connection;
/** @var AssignmentService|MockObject */
private $assignmentService;
@@ -124,6 +127,7 @@ public function setUp(): void {
$this->cardServiceValidator = $this->createMock(CardServiceValidator::class);
$this->assignmentService = $this->createMock(AssignmentService::class);
$this->referenceManager = $this->createMock(IReferenceManager::class);
+ $this->connection = $this->createMock(IDBConnection::class);
$this->logger->expects($this->any())->method('error');
@@ -149,6 +153,7 @@ public function setUp(): void {
$this->cardServiceValidator,
$this->assignmentService,
$this->referenceManager,
+ $this->connection,
'user1'
);
}
@@ -232,6 +237,8 @@ public function testCreate() {
$this->cardMapper->expects($this->once())
->method('insert')
->willReturn($card);
+ $this->connection->expects($this->never())
+ ->method('beginTransaction');
$this->stackMapper->expects($this->once())
->method('find')
->with(123)
@@ -246,6 +253,69 @@ public function testCreate() {
$this->assertEquals($b->getColor(), '00ff00');
}
+ public function testCreateAtTopReordersCardsInTransaction(): void {
+ $newCard = Card::fromParams([
+ 'id' => 3,
+ 'title' => 'New card',
+ 'owner' => 'admin',
+ 'stackId' => 123,
+ 'order' => 0,
+ 'type' => 'plain',
+ ]);
+ $firstCard = Card::fromParams(['id' => 1, 'stackId' => 123, 'order' => 0]);
+ $secondCard = Card::fromParams(['id' => 2, 'stackId' => 123, 'order' => 1]);
+ $stack = Stack::fromParams(['id' => 123, 'boardId' => 1337]);
+
+ $this->connection->expects($this->once())->method('beginTransaction');
+ $this->connection->expects($this->once())->method('commit');
+ $this->connection->expects($this->never())->method('rollBack');
+ $this->cardMapper->expects($this->once())
+ ->method('insert')
+ ->willReturn($newCard);
+ $this->cardMapper->expects($this->once())
+ ->method('findAll')
+ ->with(123)
+ ->willReturn([$firstCard, $secondCard, $newCard]);
+ $this->cardMapper->expects($this->exactly(3))
+ ->method('update')
+ ->willReturnArgument(0);
+ $this->stackMapper->expects($this->once())
+ ->method('find')
+ ->with(123)
+ ->willReturn($stack);
+
+ $createdCard = $this->cardService->create('New card', 123, 'plain', 0, 'admin', insertAtPosition: true);
+
+ $this->assertSame(0, $createdCard->getOrder());
+ $this->assertSame(1, $firstCard->getOrder());
+ $this->assertSame(2, $secondCard->getOrder());
+ }
+
+ public function testCreateAtTopRollsBackWhenReorderingFails(): void {
+ $newCard = Card::fromParams([
+ 'id' => 3,
+ 'title' => 'New card',
+ 'owner' => 'admin',
+ 'stackId' => 123,
+ 'order' => 0,
+ 'type' => 'plain',
+ ]);
+
+ $this->connection->expects($this->once())->method('beginTransaction');
+ $this->connection->expects($this->never())->method('commit');
+ $this->connection->expects($this->once())->method('rollBack');
+ $this->cardMapper->expects($this->once())
+ ->method('insert')
+ ->willReturn($newCard);
+ $this->cardMapper->expects($this->once())
+ ->method('findAll')
+ ->with(123)
+ ->willThrowException(new \RuntimeException('Could not reorder cards'));
+
+ $this->expectException(\RuntimeException::class);
+ $this->cardService->create('New card', 123, 'plain', 0, 'admin', insertAtPosition: true);
+ }
+
public function testClone() {
$card = new Card();
$card->setId(1);
From 7e5bc4c12d0d0bf258ca432da80a069ccf86eb66 Mon Sep 17 00:00:00 2001
From: Theo <36564257+theoholl@users.noreply.github.com>
Date: Sat, 8 Aug 2026 19:11:47 +0000
Subject: [PATCH 26/34] fix(create): centralize insert-at-position handling and
close add-card form after creation
- Let CardService::create() decide when to reorder based on the requested
order, so all card creation endpoints behave consistently.
- Close the inline add-card form after a card is successfully created to
prevent it from covering the new card and fix flaky Cypress tests.
Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com>
---
cypress/e2e/cardFeatures.js | 8 --------
lib/Controller/CardOcsController.php | 2 +-
lib/Service/CardService.php | 6 ++++--
src/components/board/StackCardAdd.vue | 1 +
tests/unit/Service/CardServiceTest.php | 4 ++--
5 files changed, 8 insertions(+), 13 deletions(-)
diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js
index 60ad9b916f..9634292ca9 100644
--- a/cypress/e2e/cardFeatures.js
+++ b/cypress/e2e/cardFeatures.js
@@ -110,7 +110,6 @@ describe('Card', function () {
cy.get('.stack__card-add input[type="text"]').type(title)
cy.get('.stack__card-add input[type="submit"]').click()
cy.wait('@createCard')
- cy.get('.stack__card-add input[type="text"]').type('{esc}')
})
}
@@ -380,13 +379,6 @@ describe('Card', function () {
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)
diff --git a/lib/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php
index 8049fd4f2a..cd6629d204 100644
--- a/lib/Controller/CardOcsController.php
+++ b/lib/Controller/CardOcsController.php
@@ -48,7 +48,7 @@ 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, insertAtPosition: $order !== 999);
+ $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate, $startdate, $color);
// foreach ($labels as $label) {
// $this->assignLabel($card->getId(), $label);
diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php
index d051d0205c..304576793f 100644
--- a/lib/Service/CardService.php
+++ b/lib/Service/CardService.php
@@ -189,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, bool $insertAtPosition = false): Card {
+ public function create(string $title, int $stackId, string $type, int $order, string $owner, string $description = '', $duedate = null, $startdate = null, ?string $color = null): Card {
$this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner'));
$this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_EDIT);
@@ -207,9 +207,11 @@ public function create(string $title, int $stackId, string $type, int $order, st
$card->setStartdate($startdate);
$card->setColor($color);
- if (!$insertAtPosition) {
+ if ($order === 999) {
+ // Append to the end of the stack
$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);
diff --git a/src/components/board/StackCardAdd.vue b/src/components/board/StackCardAdd.vue
index c4b8367fbc..8c8084c7c5 100644
--- a/src/components/board/StackCardAdd.vue
+++ b/src/components/board/StackCardAdd.vue
@@ -108,6 +108,7 @@ export default {
...(this.addAtTop ? { order: 0 } : {}),
})
this.title = ''
+ this.visible = false
this.$emit('created', newCard)
if (!this.cardDetailsInModal) {
this.$router.push({ name: 'card', params: { cardId: newCard.id } })
diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php
index 94a96a7a98..98eab78313 100644
--- a/tests/unit/Service/CardServiceTest.php
+++ b/tests/unit/Service/CardServiceTest.php
@@ -284,7 +284,7 @@ public function testCreateAtTopReordersCardsInTransaction(): void {
->with(123)
->willReturn($stack);
- $createdCard = $this->cardService->create('New card', 123, 'plain', 0, 'admin', insertAtPosition: true);
+ $createdCard = $this->cardService->create('New card', 123, 'plain', 0, 'admin');
$this->assertSame(0, $createdCard->getOrder());
$this->assertSame(1, $firstCard->getOrder());
@@ -313,7 +313,7 @@ public function testCreateAtTopRollsBackWhenReorderingFails(): void {
->willThrowException(new \RuntimeException('Could not reorder cards'));
$this->expectException(\RuntimeException::class);
- $this->cardService->create('New card', 123, 'plain', 0, 'admin', insertAtPosition: true);
+ $this->cardService->create('New card', 123, 'plain', 0, 'admin');
}
public function testClone() {
From 2d224ee64b684e175d753cfb9716d036efeffb92 Mon Sep 17 00:00:00 2001
From: Theo <36564257+theoholl@users.noreply.github.com>
Date: Sat, 8 Aug 2026 19:23:17 +0000
Subject: [PATCH 27/34] Revert(create): restore explicit insertAtPosition
opt-in
Inferring the reorder-on-insert behavior from `$order !== 999` broke
cloneCard() and DefaultBoardService, which call create() with real,
already-correct order values (e.g. 0) that must not trigger a shift of
other cards in the stack. Restore the explicit $insertAtPosition flag,
opted into only by the OCS controller used by the new add-card UI.
Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com>
---
lib/Controller/CardOcsController.php | 2 +-
lib/Service/CardService.php | 5 ++---
tests/unit/Service/CardServiceTest.php | 4 ++--
3 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/lib/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php
index cd6629d204..8049fd4f2a 100644
--- a/lib/Controller/CardOcsController.php
+++ b/lib/Controller/CardOcsController.php
@@ -48,7 +48,7 @@ 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);
+ $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate, $startdate, $color, insertAtPosition: $order !== 999);
// foreach ($labels as $label) {
// $this->assignLabel($card->getId(), $label);
diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php
index 304576793f..7ebc8d8a6f 100644
--- a/lib/Service/CardService.php
+++ b/lib/Service/CardService.php
@@ -189,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);
@@ -207,8 +207,7 @@ public function create(string $title, int $stackId, string $type, int $order, st
$card->setStartdate($startdate);
$card->setColor($color);
- if ($order === 999) {
- // Append to the end of the stack
+ if (!$insertAtPosition) {
$card = $this->cardMapper->insert($card);
} else {
// Insert at the requested position and shift the surrounding cards
diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php
index 98eab78313..94a96a7a98 100644
--- a/tests/unit/Service/CardServiceTest.php
+++ b/tests/unit/Service/CardServiceTest.php
@@ -284,7 +284,7 @@ public function testCreateAtTopReordersCardsInTransaction(): void {
->with(123)
->willReturn($stack);
- $createdCard = $this->cardService->create('New card', 123, 'plain', 0, 'admin');
+ $createdCard = $this->cardService->create('New card', 123, 'plain', 0, 'admin', insertAtPosition: true);
$this->assertSame(0, $createdCard->getOrder());
$this->assertSame(1, $firstCard->getOrder());
@@ -313,7 +313,7 @@ public function testCreateAtTopRollsBackWhenReorderingFails(): void {
->willThrowException(new \RuntimeException('Could not reorder cards'));
$this->expectException(\RuntimeException::class);
- $this->cardService->create('New card', 123, 'plain', 0, 'admin');
+ $this->cardService->create('New card', 123, 'plain', 0, 'admin', insertAtPosition: true);
}
public function testClone() {
From 5cd6419e3a2212fe8020f90e95e2331e1aaeb1ab Mon Sep 17 00:00:00 2001
From: Theo <36564257+theoholl@users.noreply.github.com>
Date: Sat, 8 Aug 2026 19:47:53 +0000
Subject: [PATCH 28/34] Remove unused stack__cards-list class and reorderCard
return value
Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com>
---
src/components/board/Stack.vue | 2 +-
src/stores/card.js | 1 -
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue
index 6ed6c8d8ca..0518bb12b5 100644
--- a/src/components/board/Stack.vue
+++ b/src/components/board/Stack.vue
@@ -106,7 +106,7 @@
@created="handleCardCreated" />
Date: Sat, 8 Aug 2026 20:46:45 +0000
Subject: [PATCH 29/34] Clean up review findings
- Name the sentinel order in CardOcsController instead of a magic 999
- Drop unreachable stackAddCardAtTop case from ConfigService::get()
- Restore the stronger visibility assertion in the label e2e test
Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com>
---
cypress/e2e/cardFeatures.js | 2 +-
lib/Controller/CardOcsController.php | 11 +++++++++--
lib/Service/ConfigService.php | 2 --
3 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js
index 9634292ca9..3a741858d8 100644
--- a/cypress/e2e/cardFeatures.js
+++ b/cypress/e2e/cardFeatures.js
@@ -395,7 +395,7 @@ describe('Card', function () {
cy.get(`.card:contains("${newCardTitle}")`)
.scrollIntoView({ block: 'center' })
.find('.labels li:contains("Later")')
- .should('exist')
+ .should('be.visible')
cy.get(`.card:contains("${newCardTitle}")`).find('.labels li:contains("Action needed")')
.should('not.exist')
})
diff --git a/lib/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php
index 8049fd4f2a..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, insertAtPosition: $order !== 999);
+ // 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/ConfigService.php b/lib/Service/ConfigService.php
index 580e2590d1..5a59890527 100644
--- a/lib/Service/ConfigService.php
+++ b/lib/Service/ConfigService.php
@@ -91,8 +91,6 @@ 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;
}
From f5fbe5ad575b1b395f06e313713b4a59697e0893 Mon Sep 17 00:00:00 2001
From: Theo <36564257+theoholl@users.noreply.github.com>
Date: Sat, 8 Aug 2026 21:07:57 +0000
Subject: [PATCH 30/34] Simplify bottom add-card layout
Place the bottom add-card control outside the scrollable list without overlap-specific spacing or drag handling.
Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com>
---
src/components/board/Board.vue | 8 --------
src/components/board/Stack.vue | 10 ----------
2 files changed, 18 deletions(-)
diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue
index fd12231d20..5b9cfe3f35 100644
--- a/src/components/board/Board.vue
+++ b/src/components/board/Board.vue
@@ -341,14 +341,6 @@ export default {
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 0518bb12b5..aaaa358cb2 100644
--- a/src/components/board/Stack.vue
+++ b/src/components/board/Stack.vue
@@ -8,8 +8,6 @@
:class="{
'stack--done-column': isDoneColumn,
'stack--add-card-at-top': canAddCard && stackAddCardAtTop,
- 'stack--add-card-at-bottom': canAddCard && !stackAddCardAtTop,
- 'stack--dragging-card': draggingCard,
}"
:data-cy-stack="stack.title">