feat: Implement reference resolving for cards that have a link in the title

Signed-off-by: Julius Härtl <jus@bitgrid.net>

fix: Enrich on update

Signed-off-by: Julius Knorr <jus@bitgrid.net>

fix: Enrich on create

Signed-off-by: Julius Knorr <jus@bitgrid.net>
This commit is contained in:
Julius Härtl
2024-09-01 11:58:08 +02:00
committed by Julius Knorr
parent 46c4c7d4fd
commit 75be929077
12 changed files with 155 additions and 49 deletions

View File

@@ -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', () => { describe('Modal', () => {
beforeEach(function () { beforeEach(function () {
cy.login(user) cy.login(user)

View File

@@ -58,7 +58,7 @@ use Sabre\VObject\Component\VCalendar;
class Card extends RelationalEntity { class Card extends RelationalEntity {
public const TITLE_MAX_LENGTH = 255; public const TITLE_MAX_LENGTH = 255;
protected $title; protected string $title = '';
protected $description; protected $description;
protected $descriptionPrev; protected $descriptionPrev;
protected $stackId; protected $stackId;

View File

@@ -8,10 +8,12 @@ namespace OCA\Deck\Model;
use OCA\Deck\Db\Board; use OCA\Deck\Db\Board;
use OCA\Deck\Db\Card; use OCA\Deck\Db\Card;
use OCP\Collaboration\Reference\Reference;
class CardDetails extends Card { class CardDetails extends Card {
private Card $card; private Card $card;
private ?Board $board; private ?Board $board;
private ?Reference $referenceData = null;
public function __construct(Card $card, ?Board $board = null) { public function __construct(Card $card, ?Board $board = null) {
parent::__construct(); parent::__construct();
@@ -23,6 +25,10 @@ class CardDetails extends Card {
$this->board = $board; $this->board = $board;
} }
public function setReferenceData(?Reference $data): void {
$this->referenceData = $data;
}
public function jsonSerialize(array $extras = []): array { public function jsonSerialize(array $extras = []): array {
$array = parent::jsonSerialize(); $array = parent::jsonSerialize();
$array['overdue'] = $this->getDueStatus(); $array['overdue'] = $this->getDueStatus();
@@ -38,6 +44,8 @@ class CardDetails extends Card {
$array['overdue'] = $this->getDueStatus(); $array['overdue'] = $this->getDueStatus();
$this->appendBoardDetails($array); $this->appendBoardDetails($array);
$array['referenceData'] = $this->referenceData?->jsonSerialize();
return $array; return $array;
} }

View File

@@ -29,6 +29,7 @@ use OCA\Deck\NoPermissionException;
use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\Notification\NotificationHelper;
use OCA\Deck\StatusException; use OCA\Deck\StatusException;
use OCA\Deck\Validators\CardServiceValidator; use OCA\Deck\Validators\CardServiceValidator;
use OCP\Collaboration\Reference\IReferenceManager;
use OCP\Comments\ICommentsManager; use OCP\Comments\ICommentsManager;
use OCP\EventDispatcher\IEventDispatcher; use OCP\EventDispatcher\IEventDispatcher;
use OCP\IRequest; use OCP\IRequest;
@@ -37,9 +38,6 @@ use OCP\IUserManager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class CardService { class CardService {
private ?string $currentUser;
public function __construct( public function __construct(
private CardMapper $cardMapper, private CardMapper $cardMapper,
private StackMapper $stackMapper, private StackMapper $stackMapper,
@@ -61,13 +59,13 @@ class CardService {
private IRequest $request, private IRequest $request,
private CardServiceValidator $cardServiceValidator, private CardServiceValidator $cardServiceValidator,
private AssignmentService $assignmentService, private AssignmentService $assignmentService,
?string $userId, private IReferenceManager $referenceManager,
private ?string $userId,
) { ) {
$this->currentUser = $userId;
} }
public function enrichCards($cards) { public function enrichCards($cards) {
$user = $this->userManager->get($this->currentUser); $user = $this->userManager->get($this->userId);
$cardIds = array_map(function (Card $card) use ($user) { $cardIds = array_map(function (Card $card) use ($user) {
// Everything done in here might be heavy as it is executed for every card // Everything done in here might be heavy as it is executed for every card
@@ -107,11 +105,21 @@ class CardService {
return array_map( return array_map(
function (Card $card): CardDetails { 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 $cards
); );
} }
public function fetchDeleted($boardId) { public function fetchDeleted($boardId) {
$this->cardServiceValidator->check(compact('boardId')); $this->cardServiceValidator->check(compact('boardId'));
$this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ);
@@ -191,6 +199,8 @@ class CardService {
$this->changeHelper->cardChanged($card->getId(), false); $this->changeHelper->cardChanged($card->getId(), false);
$this->eventDispatcher->dispatchTyped(new CardCreatedEvent($card)); $this->eventDispatcher->dispatchTyped(new CardCreatedEvent($card));
[$card] = $this->enrichCards([$card]);
return $card; return $card;
} }
@@ -265,7 +275,7 @@ class CardService {
} }
$changes = new ChangeSet($card); $changes = new ChangeSet($card);
if ($card->getLastEditor() !== $this->currentUser && $card->getLastEditor() !== null) { if ($card->getLastEditor() !== $this->userId && $card->getLastEditor() !== null) {
$this->activityManager->triggerEvent( $this->activityManager->triggerEvent(
ActivityManager::DECK_OBJECT_CARD, ActivityManager::DECK_OBJECT_CARD,
$card, $card,
@@ -278,7 +288,7 @@ class CardService {
); );
$card->setDescriptionPrev($card->getDescription()); $card->setDescriptionPrev($card->getDescription());
$card->setLastEditor($this->currentUser); $card->setLastEditor($this->userId);
} }
$card->setTitle($title); $card->setTitle($title);
$card->setStackId($stackId); $card->setStackId($stackId);
@@ -352,6 +362,8 @@ class CardService {
$this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $changes->getBefore())); $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $changes->getBefore()));
[$card] = $this->enrichCards([$card]);
return $card; return $card;
} }

View File

@@ -7,12 +7,12 @@
<NcAppSidebar v-if="currentBoard && currentCard" <NcAppSidebar v-if="currentBoard && currentCard"
ref="cardSidebar" ref="cardSidebar"
:active="tabId" :active="tabId"
:name="title" :name="displayTitle"
:subname="subtitle" :subname="subtitle"
:subtitle="subtitleTooltip" :subtitle="subtitleTooltip"
:name-editable="titleEditable" :name-editable.sync="isEditingTitle"
@update:nameEditable="handleUpdateTitleEditable" @update:name="(value) => titleEditing = value"
@update:name="handleUpdateTitle" @dismiss-editing="titleEditing = currentCard.title"
@submit-name="handleSubmitTitle" @submit-name="handleSubmitTitle"
@opened="focusHeader" @opened="focusHeader"
@close="closeSidebar"> @close="closeSidebar">
@@ -26,6 +26,11 @@
<CardMenuEntries :card="currentCard" :hide-details-entry="true" /> <CardMenuEntries :card="currentCard" :hide-details-entry="true" />
</template> </template>
<template #description>
<NcReferenceList v-if="currentCard.referenceData"
:text="currentCard.title"
:interactive="false" />
</template>
<NcAppSidebarTab id="details" <NcAppSidebarTab id="details"
:order="0" :order="0"
@@ -68,6 +73,7 @@
<script> <script>
import { NcActionButton, NcAppSidebar, NcAppSidebarTab } from '@nextcloud/vue' import { NcActionButton, NcAppSidebar, NcAppSidebarTab } from '@nextcloud/vue'
import { NcReferenceList } from '@nextcloud/vue/dist/Components/NcRichText.js'
import { getCapabilities } from '@nextcloud/capabilities' import { getCapabilities } from '@nextcloud/capabilities'
import { mapState, mapGetters } from 'vuex' import { mapState, mapGetters } from 'vuex'
import CardSidebarTabDetails from './CardSidebarTabDetails.vue' import CardSidebarTabDetails from './CardSidebarTabDetails.vue'
@@ -93,6 +99,7 @@ export default {
NcAppSidebar, NcAppSidebar,
NcAppSidebarTab, NcAppSidebarTab,
NcActionButton, NcActionButton,
NcReferenceList,
CardSidebarTabAttachments, CardSidebarTabAttachments,
CardSidebarTabComments, CardSidebarTabComments,
CardSidebarTabActivity, CardSidebarTabActivity,
@@ -122,7 +129,7 @@ export default {
}, },
data() { data() {
return { return {
titleEditable: false, isEditingTitle: false,
titleEditing: '', titleEditing: '',
hasActivity: capabilities && capabilities.activity, hasActivity: capabilities && capabilities.activity,
locale: getLocale(), locale: getLocale(),
@@ -130,13 +137,10 @@ export default {
}, },
computed: { computed: {
...mapState({ ...mapState({
isFullApp: state => state.isFullApp, isFullApp: (state) => state.isFullApp,
currentBoard: state => state.currentBoard, currentBoard: (state) => state.currentBoard,
}), }),
...mapGetters(['canEdit', 'assignables', 'cardActions', 'stackById']), ...mapGetters(['canEdit', 'assignables', 'cardActions', 'stackById']),
title() {
return this.titleEditable ? this.titleEditing : this.currentCard.title
},
currentCard() { currentCard() {
return this.$store.getters.cardById(this.id) return this.$store.getters.cardById(this.id)
}, },
@@ -154,11 +158,26 @@ export default {
this.$store.dispatch('setConfig', { cardDetailsInModal: newValue }) 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: { watch: {
currentCard() { currentCard() {
this.focusHeader() this.focusHeader()
}, },
'currentCard.title': {
immediate: true,
handler(newTitle) {
this.titleEditing = newTitle
},
},
}, },
methods: { methods: {
focusHeader() { focusHeader() {
@@ -166,22 +185,16 @@ export default {
this.$refs?.cardSidebar.$el.querySelector('.app-sidebar-header__mainname')?.focus() this.$refs?.cardSidebar.$el.querySelector('.app-sidebar-header__mainname')?.focus()
}) })
}, },
handleUpdateTitleEditable(value) { handleSubmitTitle() {
this.titleEditable = value if (this.titleEditing.trim() === '') {
if (value) {
this.titleEditing = this.currentCard.title
}
},
handleUpdateTitle(value) {
this.titleEditing = value
},
handleSubmitTitle(value) {
if (value.trim === '') {
showError(t('deck', 'The title cannot be empty.')) showError(t('deck', 'The title cannot be empty.'))
return return
} }
this.titleEditable = false this.isEditingTitle = false
this.$store.dispatch('updateCardTitle', { ...this.currentCard, title: this.titleEditing }) this.$store.dispatch('updateCardTitle', {
...this.currentCard,
title: this.titleEditing,
})
}, },
closeSidebar() { closeSidebar() {

View File

@@ -4,7 +4,10 @@
--> -->
<template> <template>
<div v-if="cardId && ( attachments.length > 0 )" class="card-cover"> <div v-if="referencePreview" class="card-cover">
<div class="image-wrapper rounded-left rounded-right" :style="{ backgroundImage: `url(${referencePreview})`}" />
</div>
<div v-else-if="cardId && ( attachments.length > 0 )" class="card-cover">
<div v-for="(attachment, index) in attachments" <div v-for="(attachment, index) in attachments"
:key="attachment.id" :key="attachment.id"
:class="['image-wrapper', { 'rounded-left': index === 0 }, { 'rounded-right': index === attachments.length - 1 }]" :class="['image-wrapper', { 'rounded-left': index === 0 }, { 'rounded-right': index === attachments.length - 1 }]"
@@ -43,6 +46,12 @@ export default {
attachment.extendedData.fileid ? generateUrl(`/core/preview?fileId=${attachment.extendedData.fileid}&x=${x}&y=${y}&a=1`) : null attachment.extendedData.fileid ? generateUrl(`/core/preview?fileId=${attachment.extendedData.fileid}&x=${x}&y=${y}&a=1`) : null
) )
}, },
card() {
return this.$store.getters.cardById(this.cardId)
},
referencePreview() {
return this.card?.referenceData?.richObject?.thumb
},
}, },
watch: { watch: {
cardId: { cardId: {

View File

@@ -20,7 +20,7 @@
<CardCover v-if="showCardCover" :card-id="card.id" /> <CardCover v-if="showCardCover" :card-id="card.id" />
<div class="card-upper"> <div class="card-upper">
<h4 v-if="inlineEditingBlocked" dir="auto"> <h4 v-if="inlineEditingBlocked" dir="auto">
{{ card.title }} {{ displayTitle }}
</h4> </h4>
<h4 v-else <h4 v-else
dir="auto" dir="auto"
@@ -133,11 +133,15 @@ export default {
return board ? !board.archived && board.permissions.PERMISSION_EDIT : false return board ? !board.archived && board.permissions.PERMISSION_EDIT : false
}, },
inlineEditingBlocked() { inlineEditingBlocked() {
return this.isArchived || this.showArchived || !this.canEdit || this.standalone return this.card.referenceData || this.isArchived || this.showArchived || !this.canEdit || this.standalone
}, },
card() { card() {
return this.item ? this.item : this.$store.getters.cardById(this.id) return this.item ? this.item : this.$store.getters.cardById(this.id)
}, },
displayTitle() {
const reference = this.card.referenceData
return reference ? reference.openGraphObject.name : this.card.title
},
currentCard() { currentCard() {
return this.card && this.$route && this.$route.params.cardId === this.card.id return this.card && this.$route && this.$route.params.cardId === this.card.id
}, },

View File

@@ -5,23 +5,35 @@
<template> <template>
<div v-if="card" class="card-menu" @click.stop.prevent> <div v-if="card" class="card-menu" @click.stop.prevent>
<NcButton v-if="card.referenceData" type="tertiary" @click="openLink">
<template #icon>
<LinkIcon :size="20" />
</template>
</NcButton>
<NcActions> <NcActions>
<CardMenuEntries :card="card" /> <CardMenuEntries :card="card" />
</NcActions> </NcActions>
</div> </div>
</template> </template>
<script> <script>
import { NcActions } from '@nextcloud/vue' import { NcActions, NcButton } from '@nextcloud/vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import CardMenuEntries from './CardMenuEntries.vue' import CardMenuEntries from './CardMenuEntries.vue'
export default { export default {
name: 'CardMenu', name: 'CardMenu',
components: { NcActions, CardMenuEntries }, components: { NcActions, NcButton, LinkIcon, CardMenuEntries },
props: { props: {
card: { card: {
type: Object, type: Object,
default: null, default: null,
}, },
}, },
methods: {
openLink() {
window.open(this.card?.referenceData?.openGraphObject?.link)
return false
},
},
} }
</script> </script>

View File

@@ -285,6 +285,7 @@ export default {
async updateCardTitle({ commit }, card) { async updateCardTitle({ commit }, card) {
const updatedCard = await apiClient.updateCard(card) const updatedCard = await apiClient.updateCard(card)
commit('updateCardProperty', { property: 'title', card: updatedCard }) commit('updateCardProperty', { property: 'title', card: updatedCard })
commit('updateCardProperty', { property: 'referenceData', card: updatedCard })
}, },
async moveCard({ commit }, card) { async moveCard({ commit }, card) {
const updatedCard = await apiClient.updateCard(card) const updatedCard = await apiClient.updateCard(card)

View File

@@ -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 = ''; $output = '';
$arrayIsList = array_keys($array) === range(0, count($array) - 1); $arrayIsList = array_keys($array) === range(0, count($array) - 1);
foreach ($array as $key => $value) { foreach ($array as $key => $value) {

View File

@@ -90,6 +90,7 @@ class CardTest extends TestCase {
'lastEditor' => null, 'lastEditor' => null,
'ETag' => $card->getETag(), 'ETag' => $card->getETag(),
'done' => null, 'done' => null,
'referenceData' => null,
], (new CardDetails($card))->jsonSerialize()); ], (new CardDetails($card))->jsonSerialize());
} }
public function testJsonSerializeLabels() { public function testJsonSerializeLabels() {
@@ -118,6 +119,7 @@ class CardTest extends TestCase {
'lastEditor' => null, 'lastEditor' => null,
'ETag' => $card->getETag(), 'ETag' => $card->getETag(),
'done' => false, 'done' => false,
'referenceData' => null,
], (new CardDetails($card))->jsonSerialize()); ], (new CardDetails($card))->jsonSerialize());
} }
@@ -148,6 +150,7 @@ class CardTest extends TestCase {
'lastEditor' => null, 'lastEditor' => null,
'ETag' => $card->getETag(), 'ETag' => $card->getETag(),
'done' => false, 'done' => false,
'referenceData' => null,
], (new CardDetails($card))->jsonSerialize()); ], (new CardDetails($card))->jsonSerialize());
} }
} }

View File

@@ -41,6 +41,7 @@ use OCA\Deck\Notification\NotificationHelper;
use OCA\Deck\StatusException; use OCA\Deck\StatusException;
use OCA\Deck\Validators\CardServiceValidator; use OCA\Deck\Validators\CardServiceValidator;
use OCP\Activity\IEvent; use OCP\Activity\IEvent;
use OCP\Collaboration\Reference\IReferenceManager;
use OCP\Comments\ICommentsManager; use OCP\Comments\ICommentsManager;
use OCP\EventDispatcher\IEventDispatcher; use OCP\EventDispatcher\IEventDispatcher;
use OCP\IRequest; use OCP\IRequest;
@@ -93,6 +94,8 @@ class CardServiceTest extends TestCase {
private $logger; private $logger;
/** @var CardServiceValidator|MockObject */ /** @var CardServiceValidator|MockObject */
private $cardServiceValidator; private $cardServiceValidator;
/** @var IReferenceManager|MockObject */
private $referenceManager;
/** @var AssignmentService|MockObject */ /** @var AssignmentService|MockObject */
private $assignmentService; private $assignmentService;
@@ -119,6 +122,7 @@ class CardServiceTest extends TestCase {
$this->request = $this->createMock(IRequest::class); $this->request = $this->createMock(IRequest::class);
$this->cardServiceValidator = $this->createMock(CardServiceValidator::class); $this->cardServiceValidator = $this->createMock(CardServiceValidator::class);
$this->assignmentService = $this->createMock(AssignmentService::class); $this->assignmentService = $this->createMock(AssignmentService::class);
$this->referenceManager = $this->createMock(IReferenceManager::class);
$this->logger->expects($this->any())->method('error'); $this->logger->expects($this->any())->method('error');
@@ -143,6 +147,7 @@ class CardServiceTest extends TestCase {
$this->request, $this->request,
$this->cardServiceValidator, $this->cardServiceValidator,
$this->assignmentService, $this->assignmentService,
$this->referenceManager,
'user1' 'user1'
); );
} }
@@ -207,15 +212,24 @@ class CardServiceTest extends TestCase {
} }
public function testCreate() { public function testCreate() {
$card = new Card(); $card = Card::fromParams([
$card->setTitle('Card title'); 'title' => 'Card title',
$card->setOwner('admin'); 'owner' => 'admin',
$card->setStackId(123); 'stackId' => 123,
$card->setOrder(999); 'order' => 999,
$card->setType('text'); 'type' => 'text',
]);
$stack = Stack::fromParams([
'id' => 123,
'boardId' => 1337,
]);
$this->cardMapper->expects($this->once()) $this->cardMapper->expects($this->once())
->method('insert') ->method('insert')
->willReturn($card); ->willReturn($card);
$this->stackMapper->expects($this->once())
->method('find')
->with(123)
->willReturn($stack);
$b = $this->cardService->create('Card title', 123, 'text', 999, 'admin'); $b = $this->cardService->create('Card title', 123, 'text', 999, 'admin');
$this->assertEquals($b->getTitle(), 'Card title'); $this->assertEquals($b->getTitle(), 'Card title');
@@ -270,7 +284,7 @@ class CardServiceTest extends TestCase {
$stackMock = new Stack(); $stackMock = new Stack();
$stackMock->setBoardId(1234); $stackMock->setBoardId(1234);
$this->stackMapper->expects($this->once()) $this->stackMapper->expects($this->any())
->method('find') ->method('find')
->willReturn($stackMock); ->willReturn($stackMock);
$b = $this->cardService->create('Card title', 123, 'text', 999, 'admin'); $b = $this->cardService->create('Card title', 123, 'text', 999, 'admin');
@@ -293,13 +307,23 @@ class CardServiceTest extends TestCase {
} }
public function testUpdate() { public function testUpdate() {
$card = new Card(); $card = Card::fromParams([
$card->setTitle('title'); 'title' => 'Card title',
$card->setArchived(false); '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('find')->willReturn($card);
$this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) { $this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) {
return $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); $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('newtitle', $actual->getTitle());
$this->assertEquals(234, $actual->getStackId()); $this->assertEquals(234, $actual->getStackId());