Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
56c5726
Adds settings for creating new cards at the top or bottom of stacks
theoholl Jul 19, 2026
b90474f
Add a button for adding cards to the top or bottom of a stack
theoholl Jul 19, 2026
0e0e35f
Move 'Add card' button at bottom of stack right underneath last card …
theoholl Jul 19, 2026
5842a1d
Add fade-out gradient for overflowing stacks
theoholl Jul 19, 2026
54abb50
Use a different approach for the overflow gradient because CSS `mask-…
theoholl Jul 19, 2026
8612dd3
Forms to add a new card can be close by pressing enter key
theoholl Jul 19, 2026
f5f247e
Improve position of 'Add card' button for empty stacks when setting i…
theoholl Jul 19, 2026
62aabfd
Keep the default setting adding cards to the bottom of a list
theoholl Jul 19, 2026
8fe211c
Use a real + icon instead of using text for 'Add card' button
theoholl Jul 19, 2026
d8b2e39
Removed duplicated user account check and improved method naming
theoholl Jul 19, 2026
a4b3347
Fix cypress tests
theoholl Jul 20, 2026
a1cb383
Fix a cypress test
theoholl Jul 20, 2026
b5b1c9a
Simplify JS, use CSS for layout instead if possible
theoholl Jul 22, 2026
35c6435
Avoid layout shift when clicking the 'Add to card' button in add-to-t…
theoholl Jul 22, 2026
0d68151
Use muted colors for 'Add card' button
theoholl Jul 22, 2026
98c0568
Fix dropzone for empty add-to-bottom stacks
theoholl Jul 26, 2026
3d2bcca
Remove all transitions and make remove padding from empty card lists …
theoholl Jul 28, 2026
5afd6e2
Clean up CSS selectors
theoholl Jul 28, 2026
e53ab0d
Improve drop behaviour for empty stacks
theoholl Jul 28, 2026
d0d3f9b
Simplify add-cards-to-bottom stacks
theoholl Jul 30, 2026
882d01f
Fix new card focus animation for add-to-top stacks
theoholl Jul 30, 2026
dd12331
Remove add button form duplication
theoholl Jul 30, 2026
eb69900
Extract add card form to keep tab order intact
theoholl Jul 30, 2026
7e06202
Fix card insertion position
theoholl Aug 8, 2026
edd23b6
fix(create): centralize insert-at-position handling and close add-car…
theoholl Aug 8, 2026
9082f67
Revert(create): restore explicit insertAtPosition opt-in
theoholl Aug 8, 2026
beab02c
Remove unused stack__cards-list class and reorderCard return value
theoholl Aug 8, 2026
a64d40f
Clean up review findings
theoholl Aug 8, 2026
2922493
Simplify bottom add-card layout
theoholl Aug 8, 2026
94985b7
Restore bottom add-card spacing
theoholl Aug 8, 2026
6fed78b
Fix StackCardAdd CSS covering card content in bottom mode
Copilot Aug 9, 2026
41f18c6
Add matching bottom list gradient
Copilot Aug 9, 2026
f90fc44
Fix flaky Smart picker visibility assertion
Copilot Aug 9, 2026
eb4fb1a
Fix Stack.vue conflict resolution
theoholl Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cypress/e2e/cardColor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 88 additions & 8 deletions cypress/e2e/cardFeatures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand Down Expand Up @@ -238,7 +314,9 @@ describe('Card', function () {
cy.visit(`/apps/deck/board/${boardId}`)
cy.reload()
cy.get('.board .stack').eq(0).within(() => {
cy.get(`.card:contains("${newCardTitle}")`).should('be.visible')
cy.get(`.card:contains("${newCardTitle}")`)
.scrollIntoView({ block: 'center' })
.should('be.visible')
})
})
})
Expand All @@ -260,7 +338,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)
Expand Down Expand Up @@ -296,7 +374,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)
Expand All @@ -316,7 +394,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')
Expand Down
6 changes: 3 additions & 3 deletions cypress/e2e/sharingFeatures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
Expand All @@ -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')
})
})
Expand All @@ -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()
})
})
Expand Down
11 changes: 9 additions & 2 deletions lib/Controller/CardOcsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()) {
Expand All @@ -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);
Expand Down
33 changes: 29 additions & 4 deletions lib/Service/CardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,7 @@ public function __construct(
private CardServiceValidator $cardServiceValidator,
private AssignmentService $assignmentService,
private IReferenceManager $referenceManager,
private IDBConnection $connection,
private ?string $userId,
) {
}
Expand Down Expand Up @@ -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);
Expand All @@ -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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Insertion and order normalization are one transaction, so a failed reorder cannot leave the new card stored with conflicting positions.

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);
Expand Down Expand Up @@ -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<Card>
*/
private function reorderCards(int $id, int $stackId, int $order): array {
$cardsToReorder = $this->cardMapper->findAll($stackId);
$result = [];
$i = 0;
Expand All @@ -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);
}
Expand Down
16 changes: 15 additions & 1 deletion lib/Service/ConfigService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/components/Controls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
{{ overviewName }}
</h2>
<NcActions>
<NcActionButton icon="icon-add" @click="clickShowAddCardModel">
<NcActionButton data-cy="action:add-card" icon="icon-add" @click="clickShowAddCardModel">
{{ t('deck', 'Add card') }}
</NcActionButton>
</NcActions>
Expand Down
11 changes: 11 additions & 0 deletions src/components/DeckAppSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
<NcFormBox>
<NcFormBoxSwitch v-model="cardDetailsInModal"
:label="t('deck', 'Use bigger card view')" />
<NcFormBoxSwitch v-model="stackAddCardAtTop"
data-cy="setting:add-card-at-top"
:label="t('deck', 'Add new cards at the top of a list')" />
</NcFormBox>
</NcAppSettingsSection>

Expand Down Expand Up @@ -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')
Expand Down
3 changes: 1 addition & 2 deletions src/components/board/Board.vue
Original file line number Diff line number Diff line change
Expand Up @@ -334,12 +334,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;
}
Expand Down
Loading
Loading