Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
e444694
Adds settings for creating new cards at the top or bottom of stacks
theoholl Jul 19, 2026
e2b6939
Add a button for adding cards to the top or bottom of a stack
theoholl Jul 19, 2026
8e62890
Move 'Add card' button at bottom of stack right underneath last card …
theoholl Jul 19, 2026
8912d67
Add fade-out gradient for overflowing stacks
theoholl Jul 19, 2026
ef6b136
Use a different approach for the overflow gradient because CSS `mask-…
theoholl Jul 19, 2026
60aa2d0
Forms to add a new card can be close by pressing enter key
theoholl Jul 19, 2026
0487058
Merge remote-tracking branch 'origin/main' into add-new-cards-to-top-…
theoholl Jul 19, 2026
6e41520
Improve position of 'Add card' button for empty stacks when setting i…
theoholl Jul 19, 2026
8988831
Keep the default setting adding cards to the bottom of a list
theoholl Jul 19, 2026
5a12a1a
Use a real + icon instead of using text for 'Add card' button
theoholl Jul 19, 2026
5744226
Removed duplicated user account check and improved method naming
theoholl Jul 19, 2026
050a23d
Fix cypress tests
theoholl Jul 20, 2026
fb0be1c
Merge branch 'main' into add-new-cards-to-top-or-bottom
theoholl Jul 20, 2026
bcaea9d
Fix a cypress test
theoholl Jul 20, 2026
1971a97
Simplify JS, use CSS for layout instead if possible
theoholl Jul 22, 2026
405dc44
Avoid layout shift when clicking the 'Add to card' button in add-to-t…
theoholl Jul 22, 2026
08aaf0f
Use muted colors for 'Add card' button
theoholl Jul 22, 2026
d98a261
Fix dropzone for empty add-to-bottom stacks
theoholl Jul 26, 2026
95dd1c0
Remove all transitions and make remove padding from empty card lists …
theoholl Jul 28, 2026
fc05c33
Clean up CSS selectors
theoholl Jul 28, 2026
edfa253
Improve drop behaviour for empty stacks
theoholl Jul 28, 2026
7b48ec8
Simplify add-cards-to-bottom stacks
theoholl Jul 30, 2026
a6ef37c
Fix new card focus animation for add-to-top stacks
theoholl Jul 30, 2026
1b86b77
Merge remote-tracking branch 'origin/main' into add-new-cards-to-top-…
theoholl Jul 30, 2026
50a55b7
Revert package-lock.json to `origin/main`
theoholl Jul 30, 2026
c61e8e7
Remove add button form duplication
theoholl Jul 30, 2026
d09e436
Extract add card form to keep tab order intact
theoholl Jul 30, 2026
4b351c0
Merge remote-tracking branch 'origin/main' into add-new-cards-to-top-…
theoholl Aug 8, 2026
7e606fa
Fix card insertion position
theoholl Aug 8, 2026
7e5bc4c
fix(create): centralize insert-at-position handling and close add-car…
theoholl Aug 8, 2026
2d224ee
Revert(create): restore explicit insertAtPosition opt-in
theoholl Aug 8, 2026
5cd6419
Remove unused stack__cards-list class and reorderCard return value
theoholl Aug 8, 2026
da424bd
Clean up review findings
theoholl Aug 8, 2026
f5fbe5a
Simplify bottom add-card layout
theoholl Aug 8, 2026
9a937d4
Restore bottom add-card spacing
theoholl Aug 8, 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
92 changes: 85 additions & 7 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 @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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')
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 @@ -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;
}
Expand Down
Loading
Loading