Skip to content

Commit df1c0aa

Browse files
committed
feat: allow automatic setting of 'done' status
Fixes #5485 - Marking a column to set cards as done when added to it Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent c82e6b2 commit df1c0aa

File tree

19 files changed

+1373
-765
lines changed

19 files changed

+1373
-765
lines changed

appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
['name' => 'stack#delete', 'url' => '/stacks/{stackId}', 'verb' => 'DELETE'],
4141
['name' => 'stack#deleted', 'url' => '/{boardId}/stacks/deleted', 'verb' => 'GET'],
4242
['name' => 'stack#archived', 'url' => '/stacks/{boardId}/archived', 'verb' => 'GET'],
43+
['name' => 'stack#setDoneStack', 'url' => '/stacks/{stackId}/done', 'verb' => 'PUT'],
4344

4445
// cards
4546
['name' => 'card#read', 'url' => '/cards/{cardId}', 'verb' => 'GET'],

lib/Capabilities.php

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,8 @@
1212
use OCP\Capabilities\ICapability;
1313

1414
class Capabilities implements ICapability {
15-
16-
/** @var IAppManager */
17-
private $appManager;
18-
/** @var PermissionService */
19-
private $permissionService;
15+
private IAppManager $appManager;
16+
private PermissionService $permissionService;
2017

2118

2219
public function __construct(IAppManager $appManager, PermissionService $permissionService) {
@@ -27,14 +24,15 @@ public function __construct(IAppManager $appManager, PermissionService $permissi
2724
/**
2825
* Function an app uses to return the capabilities
2926
*
30-
* @return array{deck: array{version: string, canCreateBoards: bool, apiVersions: array<string>}}
27+
* @return array{deck: array{version: string, canCreateBoards: bool, supportsDoneColumn:true, apiVersions: array<string>}}
3128
* @since 8.2.0
3229
*/
3330
public function getCapabilities() {
3431
return [
3532
'deck' => [
3633
'version' => $this->appManager->getAppVersion('deck'),
3734
'canCreateBoards' => $this->permissionService->canCreate(),
35+
'supportsDoneColumn' => true,
3836
'apiVersions' => [
3937
'1.0',
4038
'1.1'

lib/Controller/StackController.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,9 @@ public function delete(int $stackId): Stack {
7070
public function deleted(int $boardId): array {
7171
return $this->stackService->fetchDeleted($boardId);
7272
}
73+
74+
#[NoAdminRequired]
75+
public function setDoneStack(int $stackId, int $boardId, bool $isDone): void {
76+
$this->stackService->setDoneStack($stackId, $boardId, $isDone);
77+
}
7378
}

lib/Db/Stack.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
* @method \int getOrder()
2323
* @method void setOrder(int $order)
2424
* @method Card[] getCards()
25+
* @method bool getIsDoneColumn()
26+
* @method void setIsDoneColumn(bool $isDoneColumn)
2527
*/
2628
class Stack extends RelationalEntity {
2729
protected $title;
@@ -30,13 +32,15 @@ class Stack extends RelationalEntity {
3032
protected $lastModified = 0;
3133
protected $cards = [];
3234
protected $order;
35+
protected $isDoneColumn = false;
3336

3437
public function __construct() {
3538
$this->addType('id', 'integer');
3639
$this->addType('boardId', 'integer');
3740
$this->addType('deletedAt', 'integer');
3841
$this->addType('lastModified', 'integer');
3942
$this->addType('order', 'integer');
43+
$this->addType('isDoneColumn', 'boolean');
4044
}
4145

4246
public function setCards($cards) {

lib/Db/StackMapper.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,36 @@ public function update(Entity $entity): Entity {
108108
return $result;
109109
}
110110

111+
public function clearDoneColumnForBoard(int $boardId): void {
112+
$qb = $this->db->getQueryBuilder();
113+
$qb->update($this->getTableName())
114+
->set('is_done_column', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
115+
->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT)))
116+
->executeStatement();
117+
}
118+
119+
public function setIsDoneColumn(int $stackId, bool $isDone): void {
120+
$qb = $this->db->getQueryBuilder();
121+
$qb->update($this->getTableName())
122+
->set('is_done_column', $qb->createNamedParameter($isDone, IQueryBuilder::PARAM_BOOL))
123+
->where($qb->expr()->eq('id', $qb->createNamedParameter($stackId, IQueryBuilder::PARAM_INT)))
124+
->executeStatement();
125+
}
126+
127+
public function findDoneColumnForBoard(int $boardId): ?Stack {
128+
$qb = $this->db->getQueryBuilder();
129+
$qb->select('*')
130+
->from($this->getTableName())
131+
->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT)))
132+
->andWhere($qb->expr()->eq('is_done_column', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
133+
->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
134+
try {
135+
return $this->findEntity($qb);
136+
} catch (DoesNotExistException|MultipleObjectsReturnedException $e) {
137+
return null;
138+
}
139+
}
140+
111141
public function delete(Entity $entity): Entity {
112142
// delete cards on stack
113143
$this->cardMapper->deleteByStack($entity->getId());
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
declare(strict_types=1);
9+
namespace OCA\Deck\Migration;
10+
11+
use Closure;
12+
use OCP\Migration\IOutput;
13+
use OCP\Migration\SimpleMigrationStep;
14+
15+
class Version11002Date20260228000000 extends SimpleMigrationStep {
16+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
17+
$schema = $schemaClosure();
18+
19+
if ($schema->hasTable('deck_stacks')) {
20+
$table = $schema->getTable('deck_stacks');
21+
if (!$table->hasColumn('is_done_column')) {
22+
$table->addColumn('is_done_column', 'boolean', [
23+
'notnull' => false,
24+
'default' => false,
25+
]);
26+
}
27+
}
28+
return $schema;
29+
}
30+
}

lib/Service/CardService.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,21 @@ public function reorder(int $id, int $stackId, int $order): array {
430430
throw new StatusException('Operation not allowed. This card is archived.');
431431
}
432432
$changes = new ChangeSet($card);
433+
$oldStackId = $card->getStackId();
433434
$card->setStackId($stackId);
435+
436+
if ($stackId !== $oldStackId) {
437+
$newStack = $this->stackMapper->find($stackId);
438+
if ($newStack->getIsDoneColumn()) {
439+
$card->setDone(new \DateTime());
440+
} else {
441+
$oldStack = $this->stackMapper->find($oldStackId);
442+
if ($oldStack->getIsDoneColumn()) {
443+
$card->setDone(null);
444+
}
445+
}
446+
}
447+
434448
$this->cardMapper->update($card);
435449
$changes->setAfter($card);
436450
$this->activityManager->triggerUpdateEvents(ActivityManager::DECK_OBJECT_CARD, $changes, ActivityManager::SUBJECT_CARD_UPDATE);
@@ -533,6 +547,15 @@ public function done(int $id): Card {
533547
$changes = new ChangeSet($card);
534548
$card->setDone(new \DateTime());
535549
$newCard = $this->cardMapper->update($card);
550+
// Auto-move to done column if one is configured and card is not already there
551+
$currentStack = $this->stackMapper->find($newCard->getStackId());
552+
if (!$currentStack->getIsDoneColumn()) {
553+
$doneStack = $this->stackMapper->findDoneColumnForBoard($currentStack->getBoardId());
554+
if ($doneStack !== null) {
555+
$newCard->setStackId($doneStack->getId());
556+
$newCard = $this->cardMapper->update($newCard);
557+
}
558+
}
536559
$this->notificationHelper->markDuedateAsRead($card);
537560
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_DONE);
538561
$this->changeHelper->cardChanged($id, false);

lib/Service/StackService.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,37 @@ public function reorder(int $id, int $order): array {
304304

305305
return $result;
306306
}
307+
308+
/**
309+
* Set or unset a stack as the "done column" for the board
310+
*
311+
* @throws StatusException
312+
* @throws \OCA\Deck\NoPermissionException
313+
* @throws \OCP\AppFramework\Db\DoesNotExistException
314+
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
315+
* @throws BadRequestException
316+
*/
317+
public function setDoneStack(int $stackId, int $boardId, bool $isDone): void {
318+
$this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_MANAGE);
319+
320+
if ($this->boardService->isArchived($this->stackMapper, $stackId)) {
321+
throw new NoPermissionException('Operation not allowed. This board is archived.');
322+
}
323+
324+
if ($isDone) {
325+
$this->stackMapper->clearDoneColumnForBoard($boardId);
326+
// Mark all existing cards in the stack as done
327+
/** @var Card $card */
328+
foreach ($this->cardMapper->findAll($stackId) as $card) {
329+
if ($card->getDone() === null) {
330+
$card->setDone(new \DateTime());
331+
$this->cardMapper->update($card);
332+
}
333+
}
334+
}
335+
336+
$this->stackMapper->setIsDoneColumn($stackId, $isDone);
337+
$this->changeHelper->boardChanged($boardId);
338+
$this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($boardId));
339+
}
307340
}

src/components/board/Stack.vue

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,29 @@
44
-->
55

66
<template>
7-
<div class="stack" :data-cy-stack="stack.title">
7+
<div class="stack" :class="{'stack--done-column': isDoneColumn}" :data-cy-stack="stack.title">
88
<div v-click-outside="stopCardCreation"
99
class="stack__header"
10-
:class="{'stack__header--add': showAddCard}"
10+
:class="{'stack__header--add': showAddCard, 'stack__header--done-column': isDoneColumn}"
1111
:aria-label="stack.title">
1212
<transition name="fade" mode="out-in">
1313
<h3 v-if="!canManage || isArchived" tabindex="0">
1414
{{ stack.title }}
15+
<CheckCircleOutline v-if="isDoneColumn"
16+
class="stack__done-icon"
17+
decorative />
1518
</h3>
1619
<h3 v-else-if="!editing"
17-
dir="auto"
1820
tabindex="0"
1921
:aria-label="stack.title"
2022
:title="stack.title"
2123
class="stack__title"
2224
@click="startEditing(stack)"
2325
@keydown.enter="startEditing(stack)">
24-
{{ stack.title }}
26+
<span dir="auto">{{ stack.title }}</span>
27+
<CheckCircleOutline v-if="isDoneColumn"
28+
class="stack__done-icon"
29+
decorative />
2530
</h3>
2631
<form v-else-if="editing"
2732
v-click-outside="cancelEdit"
@@ -52,6 +57,12 @@
5257
</template>
5358
{{ t('deck', 'Unarchive all cards') }}
5459
</NcActionButton>
60+
<NcActionButton close-after-click @click="toggleDoneColumn">
61+
<template #icon>
62+
<CheckCircleOutline decorative />
63+
</template>
64+
{{ isDoneColumn ? t('deck', 'Do not set cards as "done"') : t('deck', 'Set cards as "done"') }}
65+
</NcActionButton>
5566
<NcActionButton icon="icon-delete" @click="deleteStack(stack)">
5667
{{ t('deck', 'Delete list') }}
5768
</NcActionButton>
@@ -140,6 +151,7 @@ import { mapGetters, mapState } from 'vuex'
140151
import { Container, Draggable } from 'vue-smooth-dnd'
141152
import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue'
142153
import CardPlusOutline from 'vue-material-design-icons/CardPlusOutline.vue'
154+
import CheckCircleOutline from 'vue-material-design-icons/CheckCircleOutline.vue'
143155
import { NcActions, NcActionButton, NcModal } from '@nextcloud/vue'
144156
import { showError, showUndo } from '@nextcloud/dialogs'
145157
@@ -158,6 +170,7 @@ export default {
158170
NcModal,
159171
ArchiveIcon,
160172
CardPlusOutline,
173+
CheckCircleOutline,
161174
},
162175
directives: {
163176
ClickOutside,
@@ -205,6 +218,9 @@ export default {
205218
return !card.archived
206219
})
207220
},
221+
isDoneColumn() {
222+
return !!this.stack.isDoneColumn
223+
},
208224
dragHandleSelector() {
209225
return this.canEdit && !this.showArchived ? null : '.no-drag'
210226
},
@@ -267,6 +283,13 @@ export default {
267283
return this.cardsByStack[index]
268284
}
269285
},
286+
toggleDoneColumn() {
287+
this.$store.dispatch('setDoneStack', {
288+
stackId: this.stack.id,
289+
boardId: this.stack.boardId,
290+
isDone: !this.isDoneColumn,
291+
})
292+
},
270293
deleteStack(stack) {
271294
this.$store.dispatch('deleteStack', stack)
272295
showUndo(t('deck', 'List deleted'), () => this.$store.dispatch('stackUndoDelete', stack))
@@ -370,6 +393,12 @@ export default {
370393
.dnd-container {
371394
flex-grow: 1;
372395
}
396+
397+
&.stack--done-column {
398+
.stack__header--done-column {
399+
border-bottom: 2px solid var(--color-success);
400+
}
401+
}
373402
}
374403
375404
.stack__header {
@@ -422,12 +451,30 @@ export default {
422451
padding: $card-padding;
423452
font-size: var(--default-font-size);
424453
454+
span {
455+
overflow: hidden;
456+
text-overflow: ellipsis;
457+
}
458+
425459
&:focus-visible {
426460
outline: 2px solid var(--color-border-dark);
427461
border-radius: 3px;
428462
}
429463
}
430464
465+
.stack__done-icon {
466+
flex-shrink: 0;
467+
color: var(--color-main-text);
468+
margin-inline-start: 2px;
469+
width: 1em;
470+
height: 1em;
471+
472+
:deep(svg) {
473+
width: 1em;
474+
height: 1em;
475+
}
476+
}
477+
431478
form {
432479
input {
433480
font-weight: bold;

src/components/cards/CardMenuEntries.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
<NcActionButton v-if="canEdit"
3131
icon="icon-checkmark"
3232
:close-after-click="true"
33+
:disabled="isInDoneColumn && !!card.done"
3334
@click="changeCardDoneStatus()">
3435
{{ card.done ? t('deck', 'Mark as not done') : t('deck', 'Mark as done') }}
3536
</NcActionButton>
@@ -110,6 +111,9 @@ export default {
110111
canEdit() {
111112
return !this.card.archived
112113
},
114+
isInDoneColumn() {
115+
return this.stackById(this.card.stackId)?.isDoneColumn === true
116+
},
113117
canEditBoard() {
114118
if (this.currentBoard) {
115119
return this.$store.getters.canEdit

0 commit comments

Comments
 (0)