From 75be92907704c4997f14ae06e205239486a1eee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Sun, 1 Sep 2024 11:58:08 +0200 Subject: [PATCH 1/5] feat: Implement reference resolving for cards that have a link in the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl fix: Enrich on update Signed-off-by: Julius Knorr fix: Enrich on create Signed-off-by: Julius Knorr --- cypress/e2e/cardFeatures.js | 20 +++++++ lib/Db/Card.php | 2 +- lib/Model/CardDetails.php | 8 +++ lib/Service/CardService.php | 30 +++++++--- src/components/card/CardSidebar.vue | 59 +++++++++++-------- src/components/cards/CardCover.vue | 11 +++- src/components/cards/CardItem.vue | 8 ++- src/components/cards/CardMenu.vue | 16 ++++- src/store/card.js | 1 + tests/integration/import/ImportExportTest.php | 2 +- tests/unit/Db/CardTest.php | 3 + tests/unit/Service/CardServiceTest.php | 44 ++++++++++---- 12 files changed, 155 insertions(+), 49 deletions(-) diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js index 0e86f5b28..4e1822be2 100644 --- a/cypress/e2e/cardFeatures.js +++ b/cypress/e2e/cardFeatures.js @@ -94,6 +94,26 @@ describe('Card', function () { }) }) + it('Card with link reference', () => { + 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"]') + .first().click() + + cy.get('.stack__card-add form input#new-stack-input-main') + .type(absoluteUrl) + cy.get('.stack__card-add form input[type=submit]') + .first().click() + cy.get('.card:contains("Example Domain")') + .should('be.visible') + .click() + + cy.get('#app-sidebar-vue') + .find('h2').contains('Example Domain').should('be.visible') + }) + }) + describe('Modal', () => { beforeEach(function () { cy.login(user) diff --git a/lib/Db/Card.php b/lib/Db/Card.php index 6813c0167..e5e0b87ae 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -58,7 +58,7 @@ use Sabre\VObject\Component\VCalendar; class Card extends RelationalEntity { public const TITLE_MAX_LENGTH = 255; - protected $title; + protected string $title = ''; protected $description; protected $descriptionPrev; protected $stackId; diff --git a/lib/Model/CardDetails.php b/lib/Model/CardDetails.php index a5410d675..39e8cf59b 100644 --- a/lib/Model/CardDetails.php +++ b/lib/Model/CardDetails.php @@ -8,10 +8,12 @@ namespace OCA\Deck\Model; use OCA\Deck\Db\Board; use OCA\Deck\Db\Card; +use OCP\Collaboration\Reference\Reference; class CardDetails extends Card { private Card $card; private ?Board $board; + private ?Reference $referenceData = null; public function __construct(Card $card, ?Board $board = null) { parent::__construct(); @@ -23,6 +25,10 @@ class CardDetails extends Card { $this->board = $board; } + public function setReferenceData(?Reference $data): void { + $this->referenceData = $data; + } + public function jsonSerialize(array $extras = []): array { $array = parent::jsonSerialize(); $array['overdue'] = $this->getDueStatus(); @@ -38,6 +44,8 @@ class CardDetails extends Card { $array['overdue'] = $this->getDueStatus(); $this->appendBoardDetails($array); + $array['referenceData'] = $this->referenceData?->jsonSerialize(); + return $array; } diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 9f1576a2e..30fd754c5 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -29,6 +29,7 @@ use OCA\Deck\NoPermissionException; use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\StatusException; use OCA\Deck\Validators\CardServiceValidator; +use OCP\Collaboration\Reference\IReferenceManager; use OCP\Comments\ICommentsManager; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; @@ -37,9 +38,6 @@ use OCP\IUserManager; use Psr\Log\LoggerInterface; class CardService { - - private ?string $currentUser; - public function __construct( private CardMapper $cardMapper, private StackMapper $stackMapper, @@ -61,13 +59,13 @@ class CardService { private IRequest $request, private CardServiceValidator $cardServiceValidator, private AssignmentService $assignmentService, - ?string $userId, + private IReferenceManager $referenceManager, + private ?string $userId, ) { - $this->currentUser = $userId; } public function enrichCards($cards) { - $user = $this->userManager->get($this->currentUser); + $user = $this->userManager->get($this->userId); $cardIds = array_map(function (Card $card) use ($user) { // Everything done in here might be heavy as it is executed for every card @@ -107,11 +105,21 @@ class CardService { return array_map( function (Card $card): CardDetails { - return new CardDetails($card); + $cardDetails = new CardDetails($card); + + $references = $this->referenceManager->extractReferences($card->getTitle()); + $reference = array_shift($references); + if ($reference) { + $referenceData = $this->referenceManager->resolveReference($reference); + $cardDetails->setReferenceData($referenceData); + } + + return $cardDetails; }, $cards ); } + public function fetchDeleted($boardId) { $this->cardServiceValidator->check(compact('boardId')); $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); @@ -191,6 +199,8 @@ class CardService { $this->changeHelper->cardChanged($card->getId(), false); $this->eventDispatcher->dispatchTyped(new CardCreatedEvent($card)); + [$card] = $this->enrichCards([$card]); + return $card; } @@ -265,7 +275,7 @@ class CardService { } $changes = new ChangeSet($card); - if ($card->getLastEditor() !== $this->currentUser && $card->getLastEditor() !== null) { + if ($card->getLastEditor() !== $this->userId && $card->getLastEditor() !== null) { $this->activityManager->triggerEvent( ActivityManager::DECK_OBJECT_CARD, $card, @@ -278,7 +288,7 @@ class CardService { ); $card->setDescriptionPrev($card->getDescription()); - $card->setLastEditor($this->currentUser); + $card->setLastEditor($this->userId); } $card->setTitle($title); $card->setStackId($stackId); @@ -352,6 +362,8 @@ class CardService { $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $changes->getBefore())); + [$card] = $this->enrichCards([$card]); + return $card; } diff --git a/src/components/card/CardSidebar.vue b/src/components/card/CardSidebar.vue index e0903594a..b3e18c9cd 100644 --- a/src/components/card/CardSidebar.vue +++ b/src/components/card/CardSidebar.vue @@ -7,12 +7,12 @@ @@ -26,6 +26,11 @@ + import { NcActionButton, NcAppSidebar, NcAppSidebarTab } from '@nextcloud/vue' +import { NcReferenceList } from '@nextcloud/vue/dist/Components/NcRichText.js' import { getCapabilities } from '@nextcloud/capabilities' import { mapState, mapGetters } from 'vuex' import CardSidebarTabDetails from './CardSidebarTabDetails.vue' @@ -93,6 +99,7 @@ export default { NcAppSidebar, NcAppSidebarTab, NcActionButton, + NcReferenceList, CardSidebarTabAttachments, CardSidebarTabComments, CardSidebarTabActivity, @@ -122,7 +129,7 @@ export default { }, data() { return { - titleEditable: false, + isEditingTitle: false, titleEditing: '', hasActivity: capabilities && capabilities.activity, locale: getLocale(), @@ -130,13 +137,10 @@ export default { }, computed: { ...mapState({ - isFullApp: state => state.isFullApp, - currentBoard: state => state.currentBoard, + isFullApp: (state) => state.isFullApp, + currentBoard: (state) => state.currentBoard, }), ...mapGetters(['canEdit', 'assignables', 'cardActions', 'stackById']), - title() { - return this.titleEditable ? this.titleEditing : this.currentCard.title - }, currentCard() { return this.$store.getters.cardById(this.id) }, @@ -154,11 +158,26 @@ export default { this.$store.dispatch('setConfig', { cardDetailsInModal: newValue }) }, }, + displayTitle: { + get() { + if (this.isEditingTitle) { + return this.titleEditing + } + const reference = this.currentCard.referenceData + return reference ? reference.openGraphObject.name : this.currentCard.title + }, + }, }, watch: { currentCard() { this.focusHeader() }, + 'currentCard.title': { + immediate: true, + handler(newTitle) { + this.titleEditing = newTitle + }, + }, }, methods: { focusHeader() { @@ -166,22 +185,16 @@ export default { this.$refs?.cardSidebar.$el.querySelector('.app-sidebar-header__mainname')?.focus() }) }, - handleUpdateTitleEditable(value) { - this.titleEditable = value - if (value) { - this.titleEditing = this.currentCard.title - } - }, - handleUpdateTitle(value) { - this.titleEditing = value - }, - handleSubmitTitle(value) { - if (value.trim === '') { + handleSubmitTitle() { + if (this.titleEditing.trim() === '') { showError(t('deck', 'The title cannot be empty.')) return } - this.titleEditable = false - this.$store.dispatch('updateCardTitle', { ...this.currentCard, title: this.titleEditing }) + this.isEditingTitle = false + this.$store.dispatch('updateCardTitle', { + ...this.currentCard, + title: this.titleEditing, + }) }, closeSidebar() { diff --git a/src/components/cards/CardCover.vue b/src/components/cards/CardCover.vue index f7d2a76d2..67dc0a167 100644 --- a/src/components/cards/CardCover.vue +++ b/src/components/cards/CardCover.vue @@ -4,7 +4,10 @@ --> diff --git a/src/store/card.js b/src/store/card.js index 63f85eb50..556a52a2e 100644 --- a/src/store/card.js +++ b/src/store/card.js @@ -285,6 +285,7 @@ export default { async updateCardTitle({ commit }, card) { const updatedCard = await apiClient.updateCard(card) commit('updateCardProperty', { property: 'title', card: updatedCard }) + commit('updateCardProperty', { property: 'referenceData', card: updatedCard }) }, async moveCard({ commit }, card) { const updatedCard = await apiClient.updateCard(card) diff --git a/tests/integration/import/ImportExportTest.php b/tests/integration/import/ImportExportTest.php index 2dbd5f8db..1d7b5d52b 100644 --- a/tests/integration/import/ImportExportTest.php +++ b/tests/integration/import/ImportExportTest.php @@ -135,7 +135,7 @@ class ImportExportTest extends \Test\TestCase { ); } - public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done']): string { + public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done', 'referenceData']): string { $output = ''; $arrayIsList = array_keys($array) === range(0, count($array) - 1); foreach ($array as $key => $value) { diff --git a/tests/unit/Db/CardTest.php b/tests/unit/Db/CardTest.php index 8c761130a..f1321b063 100644 --- a/tests/unit/Db/CardTest.php +++ b/tests/unit/Db/CardTest.php @@ -90,6 +90,7 @@ class CardTest extends TestCase { 'lastEditor' => null, 'ETag' => $card->getETag(), 'done' => null, + 'referenceData' => null, ], (new CardDetails($card))->jsonSerialize()); } public function testJsonSerializeLabels() { @@ -118,6 +119,7 @@ class CardTest extends TestCase { 'lastEditor' => null, 'ETag' => $card->getETag(), 'done' => false, + 'referenceData' => null, ], (new CardDetails($card))->jsonSerialize()); } @@ -148,6 +150,7 @@ class CardTest extends TestCase { 'lastEditor' => null, 'ETag' => $card->getETag(), 'done' => false, + 'referenceData' => null, ], (new CardDetails($card))->jsonSerialize()); } } diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php index e5600b3ae..e68775f2f 100644 --- a/tests/unit/Service/CardServiceTest.php +++ b/tests/unit/Service/CardServiceTest.php @@ -41,6 +41,7 @@ use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\StatusException; use OCA\Deck\Validators\CardServiceValidator; use OCP\Activity\IEvent; +use OCP\Collaboration\Reference\IReferenceManager; use OCP\Comments\ICommentsManager; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; @@ -93,6 +94,8 @@ class CardServiceTest extends TestCase { private $logger; /** @var CardServiceValidator|MockObject */ private $cardServiceValidator; + /** @var IReferenceManager|MockObject */ + private $referenceManager; /** @var AssignmentService|MockObject */ private $assignmentService; @@ -119,6 +122,7 @@ class CardServiceTest extends TestCase { $this->request = $this->createMock(IRequest::class); $this->cardServiceValidator = $this->createMock(CardServiceValidator::class); $this->assignmentService = $this->createMock(AssignmentService::class); + $this->referenceManager = $this->createMock(IReferenceManager::class); $this->logger->expects($this->any())->method('error'); @@ -143,6 +147,7 @@ class CardServiceTest extends TestCase { $this->request, $this->cardServiceValidator, $this->assignmentService, + $this->referenceManager, 'user1' ); } @@ -207,15 +212,24 @@ class CardServiceTest extends TestCase { } public function testCreate() { - $card = new Card(); - $card->setTitle('Card title'); - $card->setOwner('admin'); - $card->setStackId(123); - $card->setOrder(999); - $card->setType('text'); + $card = Card::fromParams([ + 'title' => 'Card title', + 'owner' => 'admin', + 'stackId' => 123, + 'order' => 999, + 'type' => 'text', + ]); + $stack = Stack::fromParams([ + 'id' => 123, + 'boardId' => 1337, + ]); $this->cardMapper->expects($this->once()) ->method('insert') ->willReturn($card); + $this->stackMapper->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($stack); $b = $this->cardService->create('Card title', 123, 'text', 999, 'admin'); $this->assertEquals($b->getTitle(), 'Card title'); @@ -270,7 +284,7 @@ class CardServiceTest extends TestCase { $stackMock = new Stack(); $stackMock->setBoardId(1234); - $this->stackMapper->expects($this->once()) + $this->stackMapper->expects($this->any()) ->method('find') ->willReturn($stackMock); $b = $this->cardService->create('Card title', 123, 'text', 999, 'admin'); @@ -293,13 +307,23 @@ class CardServiceTest extends TestCase { } public function testUpdate() { - $card = new Card(); - $card->setTitle('title'); - $card->setArchived(false); + $card = Card::fromParams([ + 'title' => 'Card title', + 'archived' => 'false', + 'stackId' => 234, + ]); + $stack = Stack::fromParams([ + 'id' => 234, + 'boardId' => 1337, + ]); $this->cardMapper->expects($this->once())->method('find')->willReturn($card); $this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) { return $c; }); + $this->stackMapper->expects($this->once()) + ->method('find') + ->with(234) + ->willReturn($stack); $actual = $this->cardService->update(123, 'newtitle', 234, 'text', 'admin', 'foo', 999, '2017-01-01 00:00:00', null); $this->assertEquals('newtitle', $actual->getTitle()); $this->assertEquals(234, $actual->getStackId()); From 047fcb65845d1d7a8904dd0e055be612bfdcf652 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 2 Jan 2025 18:10:36 +0100 Subject: [PATCH 2/5] fix: Skip individual board caches when listing all boards Signed-off-by: Julius Knorr --- .github/workflows/integration.yml | 4 ++++ cypress/e2e/cardFeatures.js | 7 ++++--- lib/Service/BoardService.php | 8 -------- tests/integration/base-query-count.txt | 2 +- tests/integration/features/bootstrap/RequestContext.php | 2 +- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index ae87ccd90..72b6c6c27 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -112,6 +112,10 @@ jobs: working-directory: apps/${{ env.APP_NAME }}/tests/integration run: ./run.sh + - name: Print log + if: always() + run: cat data/nextcloud.log + - name: Query count if: ${{ matrix.databases == 'mysql' }} uses: actions/github-script@v7 diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js index 4e1822be2..227b992d2 100644 --- a/cypress/e2e/cardFeatures.js +++ b/cypress/e2e/cardFeatures.js @@ -108,10 +108,11 @@ describe('Card', function () { cy.get('.card:contains("Example Domain")') .should('be.visible') .click() - - cy.get('#app-sidebar-vue') - .find('h2').contains('Example Domain').should('be.visible') }) + + cy.get('.app-sidebar-header', { timeout: 10000 }) + .should('be.visible') + .find('h2').contains('Example Domain').should('be.visible') }) describe('Modal', () => { diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index d8547fc28..103aaec2c 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -95,14 +95,6 @@ class BoardService { * @return Board[] */ public function findAll(int $since = -1, bool $fullDetails = false, bool $includeArchived = true): array { - if ($this->boardsCacheFull && $fullDetails) { - return $this->boardsCacheFull; - } - - if ($this->boardsCachePartial && !$fullDetails) { - return $this->boardsCachePartial; - } - $complete = $this->getUserBoards($since, $includeArchived); return $this->enrichBoards($complete, $fullDetails); } diff --git a/tests/integration/base-query-count.txt b/tests/integration/base-query-count.txt index 75dbf2694..e2f0933cf 100644 --- a/tests/integration/base-query-count.txt +++ b/tests/integration/base-query-count.txt @@ -1 +1 @@ -70520 +71221 diff --git a/tests/integration/features/bootstrap/RequestContext.php b/tests/integration/features/bootstrap/RequestContext.php index f980ea4d4..e60ff6057 100644 --- a/tests/integration/features/bootstrap/RequestContext.php +++ b/tests/integration/features/bootstrap/RequestContext.php @@ -172,7 +172,7 @@ class RequestContext implements Context { */ public function theResponseShouldBeAListOfObjects() { $jsonResponse = $this->getResponseBodyFromJson(); - Assert::assertEquals(array_keys($jsonResponse), range(0, count($jsonResponse) - 1)); + Assert::assertEquals(range(0, count($jsonResponse) - 1), array_keys($jsonResponse)); } /** From c5c8a6ef71fba5e00df3065e06e725346f99349b Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Wed, 18 Sep 2024 21:25:16 +0200 Subject: [PATCH 3/5] fix: Fix title selection and editing Signed-off-by: Julius Knorr --- src/components/cards/CardItem.vue | 52 ++++++++++++++++++------ src/components/cards/CardMenu.vue | 11 ++++- src/components/cards/CardMenuEntries.vue | 13 +++++- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/components/cards/CardItem.vue b/src/components/cards/CardItem.vue index 2eee570dc..67f1ad1d1 100644 --- a/src/components/cards/CardItem.vue +++ b/src/components/cards/CardItem.vue @@ -19,12 +19,12 @@
-

- {{ displayTitle }} +

+ {{ displayTitle }}

- +

@@ -51,7 +54,10 @@ {{ label.title }} - +
- +
@@ -78,6 +87,12 @@ import CardCover from './CardCover.vue' import DueDate from './badges/DueDate.vue' import { getCurrentUser } from '@nextcloud/auth' +const TITLE_EDITING_STATE = { + OFF: 0, + PENDING: 1, + ON: 2, +} + export default { name: 'CardItem', components: { CardBadges, AttachmentDragAndDrop, CardMenu, CardCover, DueDate }, @@ -106,6 +121,7 @@ export default { data() { return { highlight: false, + editingTitle: TITLE_EDITING_STATE.OFF, } }, computed: { @@ -132,9 +148,6 @@ export default { const board = this.$store.getters.boards.find((item) => item.id === this.card.boardId) return board ? !board.archived && board.permissions.PERMISSION_EDIT : false }, - inlineEditingBlocked() { - return this.card.referenceData || this.isArchived || this.showArchived || !this.canEdit || this.standalone - }, card() { return this.item ? this.item : this.$store.getters.cardById(this.id) }, @@ -193,15 +206,19 @@ export default { }, }, methods: { + hasSelection() { + const selection = window.getSelection() + return selection.toString() !== '' + }, focus(card) { - if (this.shortcutLock) { + if (this.shortcutLock || this.hasSelection()) { return } card = this.$refs[`card${card}`] card.focus() }, openCard() { - if (this.dragging) { + if (this.dragging || this.hasSelection()) { return } const boardId = this.card && this.card.boardId ? this.card.boardId : (this.$route?.params.id ?? this.currentBoard.id) @@ -213,8 +230,19 @@ export default { this.$root.$emit('open-card', this.card.id) }, + triggerEditTitle() { + this.editingTitle = TITLE_EDITING_STATE.PENDING + this.$store.dispatch('toggleShortcutLock', true) + setTimeout(() => { + this.$refs.titleContentEditable.focus() + this.editingTitle = TITLE_EDITING_STATE.ON + }, 0) + }, onTitleBlur(e) { - // TODO Handle empty title + if (this.editingTitle !== TITLE_EDITING_STATE.ON || e.target.innerText === '') { + return + } + this.editingTitle = TITLE_EDITING_STATE.OFF if (e.target.innerText !== this.card.title) { this.$store.dispatch('updateCardTitle', { ...this.card, diff --git a/src/components/cards/CardMenu.vue b/src/components/cards/CardMenu.vue index b9746c6d6..75f4aa6e2 100644 --- a/src/components/cards/CardMenu.vue +++ b/src/components/cards/CardMenu.vue @@ -5,13 +5,16 @@ @@ -29,11 +32,15 @@ export default { default: null, }, }, + emits: ['edit-title'], methods: { openLink() { window.open(this.card?.referenceData?.openGraphObject?.link) return false }, + editTitle(id) { + this.$emit('edit-title', id) + }, }, } diff --git a/src/components/cards/CardMenuEntries.vue b/src/components/cards/CardMenuEntries.vue index 4c6874605..373cadeae 100644 --- a/src/components/cards/CardMenuEntries.vue +++ b/src/components/cards/CardMenuEntries.vue @@ -9,6 +9,12 @@ {{ t('deck', 'Card details') }} + + + {{ t('deck', 'Edit title') }} + Date: Wed, 18 Sep 2024 22:39:20 +0200 Subject: [PATCH 4/5] fix: Inline links in card title Signed-off-by: Julius Knorr --- src/components/cards/CardItem.vue | 45 ++++++++++++++++--------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src/components/cards/CardItem.vue b/src/components/cards/CardItem.vue index 67f1ad1d1..e0ae8d141 100644 --- a/src/components/cards/CardItem.vue +++ b/src/components/cards/CardItem.vue @@ -19,10 +19,11 @@
-

- {{ displayTitle }} +

+ {{ displayTitle }}

-

@@ -33,7 +34,8 @@ @focus="onTitleFocus" @blur="onTitleBlur" @click.stop - @keyup.esc="cancelEdit" + @keyup.esc="onTitleBlur" + @keyup.enter="onTitleBlur" @keyup.stop>{{ card.title }}

@@ -152,7 +154,7 @@ export default { return this.item ? this.item : this.$store.getters.cardById(this.id) }, displayTitle() { - const reference = this.card.referenceData + const reference = this.card?.referenceData return reference ? reference.openGraphObject.name : this.card.title }, currentCard() { @@ -198,12 +200,6 @@ export default { this.$nextTick(() => this.$el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' })) } }, - 'card.title'(value) { - if (document.activeElement === this.$refs.titleContentEditable || this.$refs.titleContentEditable.textContent === value) { - return - } - this.$refs.titleContentEditable.textContent = value - }, }, methods: { hasSelection() { @@ -217,7 +213,10 @@ export default { card = this.$refs[`card${card}`] card.focus() }, - openCard() { + openCard(event) { + if (event.target.tagName.toLowerCase() === 'a') { + return + } if (this.dragging || this.hasSelection()) { return } @@ -234,19 +233,22 @@ export default { this.editingTitle = TITLE_EDITING_STATE.PENDING this.$store.dispatch('toggleShortcutLock', true) setTimeout(() => { - this.$refs.titleContentEditable.focus() + const sel = window.getSelection() + sel.selectAllChildren(this.$refs.titleContentEditable) + sel.collapseToEnd() this.editingTitle = TITLE_EDITING_STATE.ON }, 0) }, onTitleBlur(e) { - if (this.editingTitle !== TITLE_EDITING_STATE.ON || e.target.innerText === '') { + const value = e.target.innerText.trim().replace(/\n$/, '') + if (this.editingTitle !== TITLE_EDITING_STATE.ON || value === '') { return } this.editingTitle = TITLE_EDITING_STATE.OFF - if (e.target.innerText !== this.card.title) { + if (value !== this.card.title) { this.$store.dispatch('updateCardTitle', { ...this.card, - title: e.target.innerText, + title: value, }) } this.$store.dispatch('toggleShortcutLock', false) @@ -254,10 +256,6 @@ export default { onTitleFocus() { this.$store.dispatch('toggleShortcutLock', true) }, - cancelEdit() { - this.$refs.titleContentEditable.textContent = this.card.title - this.$store.dispatch('toggleShortcutLock', false) - }, handleCardKeyboardShortcut(key) { if (OCP.Accessibility.disableKeyboardShortcuts()) { return @@ -269,7 +267,7 @@ export default { switch (key.code) { case 'KeyE': - this.$refs.titleContentEditable?.focus() + this.triggerEditTitle() break case 'KeyA': this.$store.dispatch('archiveUnarchiveCard', { ...this.card, archived: !this.card.archived }) @@ -368,6 +366,11 @@ export default { word-wrap: break-word; padding-left: 4px; align-self: center; + + :deep(a) { + text-decoration: underline; + } + &.editable { span { cursor: text; From be748f5f6f74fc8241f07af74c00a01c25988d99 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Tue, 14 Jan 2025 23:19:30 +0100 Subject: [PATCH 5/5] test: Add cypress test for inline editing the card title Signed-off-by: Julius Knorr --- cypress/e2e/cardFeatures.js | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js index 227b992d2..d8ace2c9c 100644 --- a/cypress/e2e/cardFeatures.js +++ b/cypress/e2e/cardFeatures.js @@ -115,6 +115,47 @@ describe('Card', function () { .find('h2').contains('Example Domain').should('be.visible') }) + it('Rename card with link', () => { + cy.visit(`/apps/deck/#/board/${boardId}`) + const absoluteUrl = `https://example.com` + const plainTitle = 'New title' + cy.get('.board .stack').eq(0).within(() => { + cy.get('.button-vue[aria-label*="Add card"]') + .first().click() + + cy.get('.stack__card-add form input#new-stack-input-main') + .type(absoluteUrl) + cy.get('.stack__card-add form input[type=submit]') + .first().click() + cy.get('.card:contains("Example Domain")') + .should('be.visible') + }) + + // Rename link to plain title + cy.get('.card:contains("Example Domain")') + .find('.action-item__menutoggle') + .click() + cy.get('.v-popper__popper button:contains("Edit title")') + .click() + cy.get(`h4:contains("${absoluteUrl}") span[contenteditable="true"]`) + .type(`{selectAll}${plainTitle}{enter}`) + cy.get(`.card:contains("${plainTitle}")`) + .should('be.visible') + + // Rename plain title to link + cy.get('.card:contains("New title")') + .find('.action-item__menutoggle') + .click() + cy.get('.v-popper__popper button:contains("Edit title")') + .click() + cy.get('h4:contains("New title") span[contenteditable="true"]') + .type(`{selectAll}${absoluteUrl}{enter}`) + cy.get('.board').click() + cy.get('.card:contains("Example Domain")') + .should('be.visible') + + }) + describe('Modal', () => { beforeEach(function () { cy.login(user)