Compare commits

..

7 Commits

Author SHA1 Message Date
Julius Härtl
f116d4f56b Implement drag scrolling the board
Signed-off-by: Julius Härtl <jus@bitgrid.net>
2020-11-03 14:36:12 +01:00
Julius Härtl
4b8c811429 Use smooth scrolling to the center
Signed-off-by: Julius Härtl <jus@bitgrid.net>
2020-11-03 14:16:27 +01:00
Julius Härtl
8ae580fd57 Implement fading out the cards when scrolling
Signed-off-by: Julius Härtl <jus@bitgrid.net>
2020-11-03 14:16:27 +01:00
Julius Härtl
d0147cb03e Revert "Do not scroll cards into view"
This reverts commit abf67138e7.

Signed-off-by: Julius Härtl <jus@bitgrid.net>
2020-11-03 14:16:27 +01:00
Julius Härtl
c1423cde5a Fix card hover/active states
Signed-off-by: Julius Härtl <jus@bitgrid.net>
2020-11-03 14:16:27 +01:00
Julius Härtl
7efd702b24 Fix new card input positioning
Signed-off-by: Julius Härtl <jus@bitgrid.net>
2020-11-03 14:16:26 +01:00
Julius Härtl
bd9fe49ec4 Implement scrolling per stack
Signed-off-by: Julius Härtl <jus@bitgrid.net>
2020-11-03 14:16:26 +01:00
31 changed files with 648 additions and 1025 deletions

View File

@@ -146,7 +146,6 @@ OC.L10N.register(
"Undo" : "Rückgängig",
"Deleted cards" : "Gelöschte Karten",
"Share board with a user, group or circle …" : "Board mit Benutzer, Gruppe oder Kreis teilen…",
"Searching for users, groups and circles …" : "Suche nach Benutzern, Gruppen und Kreisen …",
"No participants found" : "Keine Teilnehmer gefunden",
"Board owner" : "Board-Besitzer",
"(Group)" : "(Gruppe)",

View File

@@ -144,7 +144,6 @@
"Undo" : "Rückgängig",
"Deleted cards" : "Gelöschte Karten",
"Share board with a user, group or circle …" : "Board mit Benutzer, Gruppe oder Kreis teilen…",
"Searching for users, groups and circles …" : "Suche nach Benutzern, Gruppen und Kreisen …",
"No participants found" : "Keine Teilnehmer gefunden",
"Board owner" : "Board-Besitzer",
"(Group)" : "(Gruppe)",

View File

@@ -146,7 +146,6 @@ OC.L10N.register(
"Undo" : "Rückgängig",
"Deleted cards" : "Gelöschte Karten",
"Share board with a user, group or circle …" : "Board mit Benutzer, Gruppe oder Kreis teilen…",
"Searching for users, groups and circles …" : "Suche nach Benutzern, Gruppen und Kreisen …",
"No participants found" : "Keine Teilnehmer gefunden",
"Board owner" : "Board-Besitzer",
"(Group)" : "(Gruppe)",

View File

@@ -144,7 +144,6 @@
"Undo" : "Rückgängig",
"Deleted cards" : "Gelöschte Karten",
"Share board with a user, group or circle …" : "Board mit Benutzer, Gruppe oder Kreis teilen…",
"Searching for users, groups and circles …" : "Suche nach Benutzern, Gruppen und Kreisen …",
"No participants found" : "Keine Teilnehmer gefunden",
"Board owner" : "Board-Besitzer",
"(Group)" : "(Gruppe)",

View File

@@ -146,7 +146,6 @@ OC.L10N.register(
"Undo" : "Deshacer",
"Deleted cards" : "Cartas eliminadas",
"Share board with a user, group or circle …" : "Compartir tablero con un usuario, grupo o círculo ...",
"Searching for users, groups and circles …" : "Buscando usuarios, grupos o círculos...",
"No participants found" : "No se encontraron participantes",
"Board owner" : "Propietario del tablero",
"(Group)" : "(Grupo)",

View File

@@ -144,7 +144,6 @@
"Undo" : "Deshacer",
"Deleted cards" : "Cartas eliminadas",
"Share board with a user, group or circle …" : "Compartir tablero con un usuario, grupo o círculo ...",
"Searching for users, groups and circles …" : "Buscando usuarios, grupos o círculos...",
"No participants found" : "No se encontraron participantes",
"Board owner" : "Propietario del tablero",
"(Group)" : "(Grupo)",

View File

@@ -146,7 +146,6 @@ OC.L10N.register(
"Undo" : "Annuler",
"Deleted cards" : "Cartes supprimées",
"Share board with a user, group or circle …" : "Partager le tableau avec un utilisateur, un groupe ou un cercle…",
"Searching for users, groups and circles …" : "Recherche d'utilisateurs, de groupes et de cercles ...",
"No participants found" : "Aucun participant trouvé",
"Board owner" : "Propriétaire du tableau",
"(Group)" : "(Groupe)",

View File

@@ -144,7 +144,6 @@
"Undo" : "Annuler",
"Deleted cards" : "Cartes supprimées",
"Share board with a user, group or circle …" : "Partager le tableau avec un utilisateur, un groupe ou un cercle…",
"Searching for users, groups and circles …" : "Recherche d'utilisateurs, de groupes et de cercles ...",
"No participants found" : "Aucun participant trouvé",
"Board owner" : "Propriétaire du tableau",
"(Group)" : "(Groupe)",

View File

@@ -146,7 +146,6 @@ OC.L10N.register(
"Undo" : "Desfacer",
"Deleted cards" : "Eliminar tarxetas",
"Share board with a user, group or circle …" : "Compartir taboleiro cun usuario, grupo ou círculo…",
"Searching for users, groups and circles …" : "Buscando por usuarios, grupos e círculos…",
"No participants found" : "Non se atoparon participantes",
"Board owner" : "Propietariio do taboleiro",
"(Group)" : "(grupo)",

View File

@@ -144,7 +144,6 @@
"Undo" : "Desfacer",
"Deleted cards" : "Eliminar tarxetas",
"Share board with a user, group or circle …" : "Compartir taboleiro cun usuario, grupo ou círculo…",
"Searching for users, groups and circles …" : "Buscando por usuarios, grupos e círculos…",
"No participants found" : "Non se atoparon participantes",
"Board owner" : "Propietariio do taboleiro",
"(Group)" : "(grupo)",

View File

@@ -146,7 +146,6 @@ OC.L10N.register(
"Undo" : "Annulla",
"Deleted cards" : "Schede eliminate",
"Share board with a user, group or circle …" : "Condividi lavagna con un utente, gruppo o cerchia…",
"Searching for users, groups and circles …" : "Ricerca di utenti, gruppi e cerchie…",
"No participants found" : "Nessun partecipante trovato",
"Board owner" : "Proprietario della lavagna",
"(Group)" : "(Gruppo)",

View File

@@ -144,7 +144,6 @@
"Undo" : "Annulla",
"Deleted cards" : "Schede eliminate",
"Share board with a user, group or circle …" : "Condividi lavagna con un utente, gruppo o cerchia…",
"Searching for users, groups and circles …" : "Ricerca di utenti, gruppi e cerchie…",
"No participants found" : "Nessun partecipante trovato",
"Board owner" : "Proprietario della lavagna",
"(Group)" : "(Gruppo)",

View File

@@ -146,7 +146,6 @@ OC.L10N.register(
"Undo" : "Cofnij",
"Deleted cards" : "Usunięte karty",
"Share board with a user, group or circle …" : "Udostępnij tablicę użytkownikowi, grupie lub kręgowi…",
"Searching for users, groups and circles …" : "Szukam użytkowników, grup i kręgów…",
"No participants found" : "Nie znaleziono uczestników",
"Board owner" : "Właściciel tablicy",
"(Group)" : "(Grupa)",

View File

@@ -144,7 +144,6 @@
"Undo" : "Cofnij",
"Deleted cards" : "Usunięte karty",
"Share board with a user, group or circle …" : "Udostępnij tablicę użytkownikowi, grupie lub kręgowi…",
"Searching for users, groups and circles …" : "Szukam użytkowników, grup i kręgów…",
"No participants found" : "Nie znaleziono uczestników",
"Board owner" : "Właściciel tablicy",
"(Group)" : "(Grupa)",

View File

@@ -146,7 +146,6 @@ OC.L10N.register(
"Undo" : "Geri al",
"Deleted cards" : "Silinmiş kartlar",
"Share board with a user, group or circle …" : "Panoyu kullanıcılar, gruplar ve çevrelerle paylaş …",
"Searching for users, groups and circles …" : "Kullanıcılar, gruplar ve çevreler aranıyor …",
"No participants found" : "Herhangi bir katılımcı bulunamadı",
"Board owner" : "Pano sahibi",
"(Group)" : "(Grup)",

View File

@@ -144,7 +144,6 @@
"Undo" : "Geri al",
"Deleted cards" : "Silinmiş kartlar",
"Share board with a user, group or circle …" : "Panoyu kullanıcılar, gruplar ve çevrelerle paylaş …",
"Searching for users, groups and circles …" : "Kullanıcılar, gruplar ve çevreler aranıyor …",
"No participants found" : "Herhangi bir katılımcı bulunamadı",
"Board owner" : "Pano sahibi",
"(Group)" : "(Grup)",

View File

@@ -94,8 +94,8 @@ class CardController extends Controller {
* @param $deletedAt
* @return \OCP\AppFramework\Db\Entity
*/
public function update($id, $title, $stackId, $type, $order, $description, $duedate, $deletedAt, $dueDone) {
return $this->cardService->update($id, $title, $stackId, $type, $order, $description, $this->userId, $duedate, $deletedAt, null, $dueDone);
public function update($id, $title, $stackId, $type, $order, $description, $duedate, $deletedAt) {
return $this->cardService->update($id, $title, $stackId, $type, $order, $description, $this->userId, $duedate, $deletedAt);
}
/**

View File

@@ -36,19 +36,17 @@ class Card extends RelationalEntity {
protected $lastModified;
protected $lastEditor;
protected $createdAt;
protected $labels;
protected $assignedUsers;
protected $attachments;
protected $attachmentCount;
protected $owner;
protected $order;
protected $archived = false;
protected $duedate;
protected $notified = false;
protected $deletedAt = 0;
protected $dueDone = false;
protected $labels;
protected $assignedUsers;
protected $commentsUnread = 0;
protected $attachments;
protected $attachmentCount = 0;
private $databaseType = 'sqlite';
@@ -66,8 +64,6 @@ class Card extends RelationalEntity {
$this->addType('archived', 'boolean');
$this->addType('notified', 'boolean');
$this->addType('deletedAt', 'integer');
$this->addType('dueDone', 'boolean');
$this->addRelation('labels');
$this->addRelation('assignedUsers');
$this->addRelation('attachments');

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace OCA\Deck\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version10200Date20201104190344 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('deck_cards');
if (!$table->hasColumn('due_done')) {
$table->addColumn('due_done', 'boolean', [
'notnull' => false,
'default' => false
]);
}
return $schema;
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
}

View File

@@ -261,7 +261,7 @@ class CardService {
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws BadRequestException
*/
public function update($id, $title, $stackId, $type, $order = 0, $description = '', $owner, $duedate = null, ?int $deletedAt = null, ?bool $archived = null, ?bool $dueDone = null) {
public function update($id, $title, $stackId, $type, $order = 0, $description = '', $owner, $duedate = null, $deletedAt = null, $archived = null) {
if (is_numeric($id) === false) {
throw new BadRequestException('card id must be a number');
}
@@ -320,9 +320,6 @@ class CardService {
if ($archived !== null) {
$card->setArchived($archived);
}
if ($dueDone !== null) {
$card->setDueDone($dueDone);
}
// Trigger update events before setting description as it is handled separately
@@ -593,7 +590,7 @@ class CardService {
*/
public function findAllWithDue($userId) {
$cards = $this->cardMapper->findAllWithDue($userId);
return $cards;
}
@@ -605,7 +602,7 @@ class CardService {
*/
public function findAssignedCards($userId) {
$cards = $this->cardMapper->findAssignedCards($userId);
return $cards;
}
}

161
package-lock.json generated
View File

@@ -628,103 +628,6 @@
}
}
},
"@babel/helper-module-imports": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz",
"integrity": "sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==",
"requires": {
"@babel/types": "^7.10.4"
},
"dependencies": {
"@babel/helper-validator-identifier": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz",
"integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw=="
},
"@babel/types": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz",
"integrity": "sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA==",
"requires": {
"@babel/helper-validator-identifier": "^7.10.4",
"lodash": "^4.17.19",
"to-fast-properties": "^2.0.0"
}
}
}
},
"@babel/helper-module-transforms": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz",
"integrity": "sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg==",
"requires": {
"@babel/helper-module-imports": "^7.10.4",
"@babel/helper-replace-supers": "^7.10.4",
"@babel/helper-simple-access": "^7.10.4",
"@babel/helper-split-export-declaration": "^7.11.0",
"@babel/template": "^7.10.4",
"@babel/types": "^7.11.0",
"lodash": "^4.17.19"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
"requires": {
"@babel/highlight": "^7.10.4"
}
},
"@babel/helper-split-export-declaration": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz",
"integrity": "sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg==",
"requires": {
"@babel/types": "^7.11.0"
}
},
"@babel/helper-validator-identifier": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz",
"integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw=="
},
"@babel/highlight": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz",
"integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==",
"requires": {
"@babel/helper-validator-identifier": "^7.10.4",
"chalk": "^2.0.0",
"js-tokens": "^4.0.0"
}
},
"@babel/parser": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.11.0.tgz",
"integrity": "sha512-qvRvi4oI8xii8NllyEc4MDJjuZiNaRzyb7Y7lup1NqJV8TZHF4O27CcP+72WPn/k1zkgJ6WJfnIbk4jTsVAZHw=="
},
"@babel/template": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz",
"integrity": "sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==",
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/parser": "^7.10.4",
"@babel/types": "^7.10.4"
}
},
"@babel/types": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz",
"integrity": "sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA==",
"requires": {
"@babel/helper-validator-identifier": "^7.10.4",
"lodash": "^4.17.19",
"to-fast-properties": "^2.0.0"
}
}
}
},
"@babel/helper-optimise-call-expression": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz",
@@ -916,65 +819,6 @@
}
}
},
"@babel/helper-simple-access": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz",
"integrity": "sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw==",
"requires": {
"@babel/template": "^7.10.4",
"@babel/types": "^7.10.4"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
"requires": {
"@babel/highlight": "^7.10.4"
}
},
"@babel/helper-validator-identifier": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz",
"integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw=="
},
"@babel/highlight": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz",
"integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==",
"requires": {
"@babel/helper-validator-identifier": "^7.10.4",
"chalk": "^2.0.0",
"js-tokens": "^4.0.0"
}
},
"@babel/parser": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.11.0.tgz",
"integrity": "sha512-qvRvi4oI8xii8NllyEc4MDJjuZiNaRzyb7Y7lup1NqJV8TZHF4O27CcP+72WPn/k1zkgJ6WJfnIbk4jTsVAZHw=="
},
"@babel/template": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz",
"integrity": "sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==",
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/parser": "^7.10.4",
"@babel/types": "^7.10.4"
}
},
"@babel/types": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz",
"integrity": "sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA==",
"requires": {
"@babel/helper-validator-identifier": "^7.10.4",
"lodash": "^4.17.19",
"to-fast-properties": "^2.0.0"
}
}
}
},
"@babel/helper-skip-transparent-expression-wrappers": {
"version": "7.12.1",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz",
@@ -20478,6 +20322,11 @@
"tinycolor2": "^1.1.2"
}
},
"vue-dragscroll": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/vue-dragscroll/-/vue-dragscroll-2.1.0.tgz",
"integrity": "sha512-ZNcvOdrnq9w4US2RqZOfp2cCJzEz1p4xZ1w4I+xKSAFlEOrXbryLe/iclspHH57dco5QT3Azd8/KD930WzadiQ=="
},
"vue-easymde": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/vue-easymde/-/vue-easymde-1.3.0.tgz",

View File

@@ -54,6 +54,7 @@
"vue": "^2.6.12",
"vue-at": "^2.5.0-beta.2",
"vue-click-outside": "^1.1.0",
"vue-dragscroll": "^2.1.0",
"vue-easymde": "^1.3.0",
"vue-infinite-loading": "^2.4.5",
"vue-router": "^3.4.8",

View File

@@ -49,13 +49,13 @@
</form>
</template>
</EmptyContent>
<div v-else-if="!isEmpty && !loading" key="board" class="board">
<div v-else-if="!isEmpty && !loading" key="board" class="board" v-dragscroll:nochilddrag>
<Container lock-axix="y"
orientation="horizontal"
:drag-handle-selector="dragHandleSelector"
@drop="onDropStack">
<Draggable v-for="stack in stacksByBoard" :key="stack.id">
<Stack :stack="stack" />
<Stack :stack="stack" data-dragscroll />
</Draggable>
</Container>
</div>
@@ -75,7 +75,7 @@ import { mapState, mapGetters } from 'vuex'
import Controls from '../Controls'
import Stack from './Stack'
import { EmptyContent } from '@nextcloud/vue'
import { dragscroll } from 'vue-dragscroll'
export default {
name: 'Board',
components: {
@@ -88,6 +88,9 @@ export default {
inject: [
'boardApi',
],
directives: {
dragscroll,
},
props: {
id: {
type: Number,
@@ -112,7 +115,7 @@ export default {
return this.$store.getters.stacksByBoard(this.board.id)
},
dragHandleSelector() {
return this.canEdit ? null : '.no-drag'
return this.canEdit ? '.stack--header' : '.no-drag'
},
isEmpty() {
return this.stacksByBoard.length === 0
@@ -194,6 +197,8 @@ export default {
.smooth-dnd-container.horizontal {
display: flex;
align-items: stretch;
height: 100%;
.smooth-dnd-draggable-wrapper::v-deep {
display: flex;
height: auto;
@@ -201,21 +206,17 @@ export default {
.stack {
display: flex;
flex-direction: column;
position: relative;
.smooth-dnd-container.vertical {
flex-grow: 1;
display: flex;
flex-direction: column;
padding: 0;
/**
* Use this to scroll each stack individually
* This currenly has the issue that the popover menu will be cut off
*/
/*
overflow-x: scroll;
height: calc(100vh - 50px - 44px * 2 - 30px);
max-height: calc(100vh - 50px - 44px * 2 - 30px);
*/
padding: $stack-spacing;
overflow-x: hidden;
overflow-y: auto;
padding-top: 15px;
margin-top: -10px;
}
.smooth-dnd-container.vertical > .smooth-dnd-draggable-wrapper {

View File

@@ -23,7 +23,7 @@
<template>
<div class="stack">
<div v-click-outside="stopCardCreation" class="stack--header">
<div v-click-outside="stopCardCreation" class="stack--header" :class="{'stack--header--add': showAddCard }">
<transition name="fade" mode="out-in">
<h3 v-if="!canManage || isArchived">
{{ stack.title }}
@@ -267,9 +267,9 @@ export default {
@import './../../css/variables';
.stack {
width: $stack-width;
margin-left: $stack-spacing;
margin-right: $stack-spacing;
width: $stack-width + $stack-spacing*3;
margin-left: $stack-spacing/2;
margin-right: $stack-spacing/2;
}
.stack--header {
@@ -277,14 +277,33 @@ export default {
position: sticky;
top: 0;
z-index: 100;
padding: 3px;
margin: 3px -3px;
margin-right: -10px;
margin-top: 0;
margin-bottom: 3px;
background-color: var(--color-main-background-translucent);
padding-left: $card-spacing;
cursor: grab;
// Smooth fade out of the cards at the top
&:before {
content: ' ';
display: block;
position: absolute;
background-image: linear-gradient(180deg, var(--color-main-background) 3px, transparent 100%);
width: 100%;
height: 25px;
top: 35px;
right: 6px;
z-index: 99;
transition: top var(--animation-slow);
}
&--add:before {
height: 80px;
background-image: linear-gradient(180deg, var(--color-main-background) 68px, transparent 100%);
}
& > * {
position: relative;
z-index: 100;
}
h3, form {
flex-grow: 1;
display: flex;
@@ -304,22 +323,20 @@ export default {
}
.stack--card-add {
position: sticky;
top: 52px;
height: 52px;
z-index: 100;
display: flex;
margin-left: 12px;
margin-right: 12px;
margin-top: 5px;
margin-bottom: 20px;
background-color: var(--color-main-background);
margin-left: -10px;
margin-right: -10px;
padding-top: 3px;
form {
display: flex;
width: 100%;
margin: 10px;
margin-top: 0;
margin-bottom: 10px;
margin: 0;
margin-right: 6px;
box-shadow: 0 0 3px var(--color-box-shadow);
border-radius: var(--border-radius-large);
overflow: hidden;
@@ -339,10 +356,6 @@ export default {
}
}
.stack .smooth-dnd-container.vertical {
margin-top: 3px;
}
/**
* Rules to handle scrolling behaviour are inherited from Board.vue
*/

View File

@@ -21,13 +21,11 @@
-->
<template>
<AppSidebar v-if="currentBoard && currentCard"
:title="title"
<AppSidebar v-if="currentBoard && currentCard && copiedCard"
:title="currentCard.title"
:subtitle="subtitle"
:title-editable="titleEditable"
@update:titleEditable="handleUpdateTitleEditable"
@update:title="handleUpdateTitle"
@submit-title="handleSubmitTitle"
:title-editable.sync="titleEditable"
@update:title="updateTitle"
@close="closeSidebar">
<template #secondary-actions>
<ActionButton v-if="cardDetailsInModal" icon="icon-menu-sidebar" @click.stop="showModal()">
@@ -43,7 +41,135 @@
:order="0"
:name="t('deck', 'Details')"
icon="icon-home">
<CardSidebarTabDetails :card="currentCard" />
<div class="section-wrapper">
<div v-tooltip="t('deck', 'Tags')" class="section-label icon-tag">
<span class="hidden-visually">{{ t('deck', 'Tags') }}</span>
</div>
<div class="section-details">
<Multiselect v-model="assignedLabels"
:multiple="true"
:disabled="!canEdit"
:options="labelsSorted"
:placeholder="t('deck', 'Assign a tag to this card…')"
:taggable="true"
label="title"
track-by="id"
@select="addLabelToCard"
@remove="removeLabelFromCard">
<template #option="scope">
<div :style="{ backgroundColor: '#' + scope.option.color, color: textColor(scope.option.color)}" class="tag">
{{ scope.option.title }}
</div>
</template>
<template #tag="scope">
<div :style="{ backgroundColor: '#' + scope.option.color, color: textColor(scope.option.color)}" class="tag">
{{ scope.option.title }}
</div>
</template>
</Multiselect>
</div>
</div>
<div class="section-wrapper">
<div v-tooltip="t('deck', 'Assign to users')" class="section-label icon-group">
<span class="hidden-visually">{{ t('deck', 'Assign to users/groups/circles') }}</span>
</div>
<div class="section-details">
<Multiselect v-if="canEdit"
v-model="assignedUsers"
:multiple="true"
:options="formatedAssignables"
:user-select="true"
:auto-limit="false"
:placeholder="t('deck', 'Assign a user to this card…')"
label="displayname"
track-by="multiselectKey"
@select="assignUserToCard"
@remove="removeUserFromCard">
<template #tag="scope">
<div class="avatarlist--inline">
<Avatar :user="scope.option.uid"
:display-name="scope.option.displayname"
:size="24"
:is-no-user="scope.option.isNoUser"
:disable-menu="true" />
</div>
</template>
</Multiselect>
<div v-else class="avatar-list--readonly">
<Avatar v-for="option in assignedUsers"
:key="option.primaryKey"
:user="option.uid"
:display-name="option.displayname"
:is-no-user="option.isNoUser"
:size="32" />
</div>
</div>
</div>
<div class="section-wrapper">
<div v-tooltip="t('deck', 'Due date')" class="section-label icon-calendar-dark">
<span class="hidden-visually">{{ t('deck', 'Due date') }}</span>
</div>
<div class="section-details">
<DatetimePicker v-model="duedate"
:placeholder="t('deck', 'Set a due date')"
type="datetime"
:minute-step="5"
:show-second="false"
:lang="lang"
:format="format"
:disabled="saving || !canEdit"
confirm />
<Actions v-if="canEdit">
<ActionButton v-if="copiedCard.duedate" icon="icon-delete" @click="removeDue()">
{{ t('deck', 'Remove due date') }}
</ActionButton>
</Actions>
</div>
</div>
<div class="section-wrapper">
<CollectionList v-if="currentCard.id"
:id="`${currentCard.id}`"
:name="currentCard.title"
type="deck-card" />
</div>
<h5>
{{ t('deck', 'Description') }}
<span v-if="copiedCard.descriptionLastEdit && !descriptionSaving">{{ t('deck', '(Unsaved)') }}</span>
<span v-if="descriptionSaving">{{ t('deck', '(Saving)') }}</span>
<a v-tooltip="t('deck', 'Formatting help')"
href="https://deck.readthedocs.io/en/latest/Markdown/"
target="_blank"
class="icon icon-info" />
<Actions v-if="canEdit">
<ActionButton v-if="!descriptionEditing" icon="icon-rename" @click="showEditor()">
{{ t('deck', 'Edit description') }}
</ActionButton>
<ActionButton v-else icon="icon-toggle" @click="hideEditor()">
{{ t('deck', 'View description') }}
</ActionButton>
</Actions>
<Actions v-if="canEdit">
<ActionButton v-if="descriptionEditing" icon="icon-attach" @click="showAttachmentModal()">
{{ t('deck', 'Add Attachment') }}
</ActionButton>
</Actions>
</h5>
<div v-if="!descriptionEditing"
id="description-preview"
@click="clickedPreview"
v-html="renderedDescription" />
<VueEasymde v-else
:key="copiedCard.id"
ref="markdownEditor"
v-model="copiedCard.description"
:configs="mdeConfig"
@input="updateDescription"
@blur="saveDescription" />
</AppSidebarTab>
<AppSidebarTab id="attachments"
@@ -68,20 +194,46 @@
icon="icon-activity">
<CardSidebarTabActivity :card="currentCard" />
</AppSidebarTab>
<Modal v-if="modalShow" :title="t('deck', 'Choose attachment')" @close="modalShow=false">
<div class="modal__content">
<h3>{{ t('deck', 'Choose attachment') }}</h3>
<AttachmentList
:card-id="currentCard.id"
:selectable="true"
@selectAttachment="addAttachment" />
</div>
</Modal>
</AppSidebar>
</template>
<script>
import { ActionButton, AppSidebar, AppSidebarTab } from '@nextcloud/vue'
import { Avatar, Actions, ActionButton, Multiselect, AppSidebar, AppSidebarTab, DatetimePicker, Modal } from '@nextcloud/vue'
import { mapState, mapGetters } from 'vuex'
import CardSidebarTabDetails from './CardSidebarTabDetails'
import Color from '../../mixins/color'
import { CollectionList } from 'nextcloud-vue-collections'
import CardSidebarTabAttachments from './CardSidebarTabAttachments'
import CardSidebarTabComments from './CardSidebarTabComments'
import CardSidebarTabActivity from './CardSidebarTabActivity'
import MarkdownIt from 'markdown-it'
import MarkdownItTaskLists from 'markdown-it-task-lists'
import { formatFileSize } from '@nextcloud/files'
import relativeDate from '../../mixins/relativeDate'
import AttachmentList from './AttachmentList'
import { generateUrl } from '@nextcloud/router'
import {
getLocale,
getDayNamesMin,
getFirstDay,
getMonthNamesShort,
} from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import { showError } from '@nextcloud/dialogs'
const markdownIt = new MarkdownIt({
linkify: true,
})
markdownIt.use(MarkdownItTaskLists, { enabled: true, label: true, labelAfter: true })
const capabilities = window.OC.getCapabilities()
export default {
@@ -89,13 +241,20 @@ export default {
components: {
AppSidebar,
AppSidebarTab,
Multiselect,
DatetimePicker,
VueEasymde: () => import('vue-easymde/dist/VueEasyMDE.common'),
Actions,
ActionButton,
Avatar,
CollectionList,
CardSidebarTabAttachments,
CardSidebarTabComments,
CardSidebarTabActivity,
CardSidebarTabDetails,
Modal,
AttachmentList,
},
mixins: [relativeDate],
mixins: [Color, relativeDate],
props: {
id: {
type: Number,
@@ -104,9 +263,41 @@ export default {
},
data() {
return {
assignedUsers: null,
addedLabelToCard: null,
copiedCard: null,
assignedLabels: null,
locale: getLocale(),
saving: false,
markdownIt: null,
titleEditable: false,
titleEditing: '',
descriptionEditing: false,
mdeConfig: {
autoDownloadFontAwesome: false,
spellChecker: false,
autofocus: true,
autosave: { enabled: false, uniqueId: 'unique' },
toolbar: false,
},
descriptionSaveTimeout: null,
descriptionSaving: false,
hasActivity: capabilities && capabilities.activity,
modalShow: false,
lang: {
days: getDayNamesMin(),
months: getMonthNamesShort(),
formatLocale: {
firstDayOfWeek: getFirstDay() === 0 ? 7 : getFirstDay(),
},
placeholder: {
date: t('deck', 'Select Date'),
},
},
format: {
stringify: this.stringify,
parse: this.parse,
},
}
},
computed: {
@@ -115,8 +306,23 @@ export default {
cardDetailsInModal: state => state.cardDetailsInModal,
}),
...mapGetters(['canEdit', 'assignables']),
title() {
return this.titleEditable ? this.titleEditing : this.currentCard.title
attachments() {
return [...this.$store.getters.attachmentsByCard(this.id)].sort((a, b) => b.id - a.id)
},
mimetypeForAttachment() {
return (mimetype) => {
const url = OC.MimeType.getIconUrl(mimetype)
const styles = {
'background-image': `url("${url}")`,
}
return styles
}
},
attachmentUrl() {
return (attachment) => generateUrl(`/apps/deck/cards/${attachment.cardId}/attachment/${attachment.id}`)
},
formattedFileSize() {
return (filesize) => formatFileSize(filesize)
},
currentCard() {
return this.$store.getters.cardById(this.id)
@@ -124,30 +330,214 @@ export default {
subtitle() {
return t('deck', 'Modified') + ': ' + this.relativeDate(this.currentCard.lastModified * 1000) + ' ' + t('deck', 'Created') + ': ' + this.relativeDate(this.currentCard.createdAt * 1000)
},
formatedAssignables() {
return this.assignables.map(item => {
const assignable = {
...item,
user: item.primaryKey,
displayName: item.displayname,
icon: 'icon-user',
isNoUser: false,
multiselectKey: item.type + ':' + item.uid,
}
if (item.type === 1) {
assignable.icon = 'icon-group'
assignable.isNoUser = true
}
if (item.type === 7) {
assignable.icon = 'icon-circles'
assignable.isNoUser = true
}
return assignable
})
},
duedate: {
get() {
return this.currentCard.duedate ? new Date(this.currentCard.duedate) : null
},
async set(val) {
this.saving = true
await this.$store.dispatch('updateCardDue', {
...this.copiedCard,
duedate: val ? (new Date(val)).toISOString() : null,
})
this.saving = false
},
},
renderedDescription() {
return markdownIt.render(this.copiedCard.description || '')
},
labelsSorted() {
return [...this.currentBoard.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
},
watch: {
currentCard() {
this.initialize()
},
},
mounted() {
this.initialize()
},
methods: {
handleUpdateTitleEditable(value) {
this.titleEditable = value
if (value) {
this.titleEditing = this.currentCard.title
async initialize() {
if (!this.currentCard) {
return
}
if (this.copiedCard) {
await this.saveDescription()
}
this.copiedCard = JSON.parse(JSON.stringify(this.currentCard))
this.assignedLabels = [...this.currentCard.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
if (this.currentCard.assignedUsers && this.currentCard.assignedUsers.length > 0) {
this.assignedUsers = this.currentCard.assignedUsers.map((item) => ({
...item.participant,
isNoUser: item.participant.type !== 0,
multiselectKey: item.participant.type + ':' + item.participant.primaryKey,
}))
} else {
this.assignedUsers = []
}
this.desc = this.currentCard.description
},
showEditor() {
if (!this.canEdit) {
return
}
this.descriptionEditing = true
},
hideEditor() {
this.descriptionEditing = false
},
showAttachmentModal() {
this.modalShow = true
},
addAttachment(attachment) {
const descString = this.$refs.markdownEditor.easymde.value()
let embed = ''
if (attachment.extendedData.mimetype.includes('image')) {
embed = '!'
}
const attachmentString = embed + '[📎 ' + attachment.data + '](' + this.attachmentUrl(attachment) + ')'
this.$refs.markdownEditor.easymde.value(descString + '\n' + attachmentString)
this.modalShow = false
},
clickedPreview(e) {
if (e.target.getAttribute('type') === 'checkbox') {
const clickedIndex = [...document.querySelector('#description-preview').querySelectorAll('input')].findIndex((li) => li.id === e.target.id)
const reg = /\[(X|\s|_|-)\]/ig
let nth = 0
const updatedDescription = this.copiedCard.description.replace(reg, (match, i, original) => {
let result = match
if ('' + nth++ === '' + clickedIndex) {
if (match.match(/^\[\s\]/i)) {
result = match.replace(/\[\s\]/i, '[x]')
}
if (match.match(/^\[x\]/i)) {
result = match.replace(/\[x\]/i, '[ ]')
}
return result
}
return match
})
this.$set(this.copiedCard, 'description', updatedDescription)
this.$store.dispatch('updateCardDesc', this.copiedCard)
}
},
handleUpdateTitle(value) {
this.titleEditing = value
setDue() {
this.$store.dispatch('updateCardDue', this.copiedCard)
},
handleSubmitTitle(value) {
if (value.trim === '') {
removeDue() {
this.copiedCard.duedate = null
this.$store.dispatch('updateCardDue', this.copiedCard)
},
async saveDescription() {
if (!Object.prototype.hasOwnProperty.call(this.copiedCard, 'descriptionLastEdit') || this.descriptionSaving) {
return
}
this.descriptionSaving = true
await this.$store.dispatch('updateCardDesc', this.copiedCard)
delete this.copiedCard.descriptionLastEdit
this.descriptionSaving = false
},
updateTitle(newTitle) {
if (newTitle.trim === '') {
showError(t('deck', 'The title cannot be empty.'))
return
}
this.titleEditable = false
this.$store.dispatch('updateCardTitle', { ...this.currentCard, title: this.titleEditing })
this.$set(this.copiedCard, 'title', newTitle)
this.$store.dispatch('updateCardTitle', this.copiedCard).then(() => {
this.titleEditable = false
})
},
updateDescription() {
this.copiedCard.descriptionLastEdit = Date.now()
clearTimeout(this.descriptionSaveTimeout)
this.descriptionSaveTimeout = setTimeout(async() => {
await this.saveDescription()
}, 2500)
},
closeSidebar() {
this.$router.push({ name: 'board' })
},
assignUserToCard(user) {
this.$store.dispatch('assignCardToUser', {
card: this.copiedCard,
assignee: {
userId: user.uid,
type: user.type,
},
})
},
removeUserFromCard(user) {
this.$store.dispatch('removeUserFromCard', {
card: this.copiedCard,
assignee: {
userId: user.uid,
type: user.type,
},
})
},
addLabelToCard(newLabel) {
this.copiedCard.labels.push(newLabel)
const data = {
card: this.copiedCard,
labelId: newLabel.id,
}
this.$store.dispatch('addLabel', data)
},
removeLabelFromCard(removedLabel) {
const removeIndex = this.copiedCard.labels.findIndex((label) => {
return label.id === removedLabel.id
})
if (removeIndex !== -1) {
this.copiedCard.labels.splice(removeIndex, 1)
}
const data = {
card: this.copiedCard,
labelId: removedLabel.id,
}
this.$store.dispatch('removeLabel', data)
},
stringify(date) {
return moment(date).locale(this.locale).format('LLL')
},
parse(value) {
return moment(value).toDate()
},
showModal() {
this.$store.dispatch('setCardDetailsInModal', true)
},
@@ -155,6 +545,27 @@ export default {
}
</script>
<style>
@import '~easymde/dist/easymde.min.css';
.vue-easymde, .CodeMirror {
border: none;
margin: 0;
padding: 0;
background-color: var(--color-main-background);
color: var(--color-main-text);
}
.editor-preview,
.editor-statusbar {
display: none;
}
#app-sidebar .app-sidebar-header__desc h4 {
font-size: 12px !important;
}
</style>
<style lang="scss" scoped>
// FIXME: Obivously we should at some point not randomly reuse the sidebar component
@@ -187,8 +598,6 @@ export default {
section {
min-height: auto;
display: flex;
flex-direction: column;
}
#emptycontent, .emptycontent {
@@ -197,4 +606,128 @@ export default {
}
}
h5 {
border-bottom: 1px solid var(--color-border);
margin-top: 20px;
margin-bottom: 5px;
color: var(--color-text-maxcontrast);
.icon-info {
display: inline-block;
width: 32px;
height: 16px;
float: right;
opacity: .7;
}
.icon-attach {
background-size: 16px;
float: right;
margin-top: -14px;
opacity: .7;
}
.icon-toggle, .icon-rename {
float: right;
margin-top: -14px;
}
}
aside::v-deep section {
display: flex;
flex-direction: column;
}
.section-wrapper {
display: flex;
max-width: 100%;
margin-top: 10px;
.section-label {
background-position: 0px center;
width: 28px;
margin-left: 9px;
flex-shrink: 0;
}
.section-details {
flex-grow: 1;
button.action-item--single {
margin-top: -6px;
}
}
}
.tag {
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
padding: 0px 5px;
border-radius: 15px;
font-size: 85%;
margin-right: 3px;
}
.avatarLabel {
padding: 6px
}
.section-details::v-deep .multiselect__tags-wrap {
flex-wrap: wrap;
}
.avatar-list--readonly .avatardiv {
margin-right: 3px;
}
.avatarlist--inline {
display: flex;
align-items: center;
margin-right: 3px;
.avatarLabel {
padding: 0;
}
}
.multiselect::v-deep .multiselect__tags-wrap {
z-index: 2;
}
.multiselect.multiselect--active::v-deep .multiselect__tags-wrap {
z-index: 0;
}
#description-preview {
min-height: 100px;
&::v-deep {
@import './../../css/markdown';
}
&::v-deep input {
min-height: auto;
}
&::v-deep a {
text-decoration: underline;
}
}
.modal__content {
width: 25vw;
min-width: 250px;
min-height: 120px;
margin: 20px;
padding-bottom: 20px;
display: flex;
flex-direction: column;
&::v-deep .attachment-list {
flex-shrink: 1;
overflow: scroll;
max-height: 50vh;
}
}
</style>

View File

@@ -1,379 +0,0 @@
<!--
- @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
-
- @author Julius Härtl <jus@bitgrid.net>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div v-if="copiedCard">
<div class="section-wrapper">
<div v-tooltip="t('deck', 'Tags')" class="section-label icon-tag">
<span class="hidden-visually">{{ t('deck', 'Tags') }}</span>
</div>
<div class="section-details">
<Multiselect v-model="assignedLabels"
:multiple="true"
:disabled="!canEdit"
:options="labelsSorted"
:placeholder="t('deck', 'Assign a tag to this card…')"
:taggable="true"
label="title"
track-by="id"
@select="addLabelToCard"
@remove="removeLabelFromCard">
<template #option="scope">
<div :style="{ backgroundColor: '#' + scope.option.color, color: textColor(scope.option.color)}" class="tag">
{{ scope.option.title }}
</div>
</template>
<template #tag="scope">
<div :style="{ backgroundColor: '#' + scope.option.color, color: textColor(scope.option.color)}" class="tag">
{{ scope.option.title }}
</div>
</template>
</Multiselect>
</div>
</div>
<div class="section-wrapper">
<div v-tooltip="t('deck', 'Assign to users')" class="section-label icon-group">
<span class="hidden-visually">{{ t('deck', 'Assign to users/groups/circles') }}</span>
</div>
<div class="section-details">
<Multiselect v-if="canEdit"
v-model="assignedUsers"
:multiple="true"
:options="formatedAssignables"
:user-select="true"
:auto-limit="false"
:placeholder="t('deck', 'Assign a user to this card…')"
label="displayname"
track-by="multiselectKey"
@select="assignUserToCard"
@remove="removeUserFromCard">
<template #tag="scope">
<div class="avatarlist--inline">
<Avatar :user="scope.option.uid"
:display-name="scope.option.displayname"
:size="24"
:is-no-user="scope.option.isNoUser"
:disable-menu="true" />
</div>
</template>
</Multiselect>
<div v-else class="avatar-list--readonly">
<Avatar v-for="option in assignedUsers"
:key="option.primaryKey"
:user="option.uid"
:display-name="option.displayname"
:is-no-user="option.isNoUser"
:size="32" />
</div>
</div>
</div>
<div class="section-wrapper">
<div v-tooltip="t('deck', 'Due date')" class="section-label icon-calendar-dark">
<span class="hidden-visually">{{ t('deck', 'Due date') }}</span>
</div>
<div class="section-details">
<DatetimePicker v-model="duedate"
:placeholder="t('deck', 'Set a due date')"
type="datetime"
:minute-step="5"
:show-second="false"
:lang="lang"
:formatter="format"
:disabled="saving || !canEdit"
confirm />
<Actions v-if="canEdit">
<ActionButton v-if="copiedCard.duedate" icon="icon-delete" @click="removeDue()">
{{ t('deck', 'Remove due date') }}
</ActionButton>
</Actions>
</div>
</div>
<div class="section-wrapper">
<CollectionList v-if="card.id"
:id="`${card.id}`"
:name="card.title"
type="deck-card" />
</div>
<Description :card="card" />
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import moment from '@nextcloud/moment'
import { Avatar, Actions, ActionButton, Multiselect, DatetimePicker } from '@nextcloud/vue'
import { CollectionList } from 'nextcloud-vue-collections'
import Color from '../../mixins/color'
import {
getLocale,
getDayNamesMin,
getFirstDay,
getMonthNamesShort,
} from '@nextcloud/l10n'
import Description from './Description'
export default {
name: 'CardSidebarTabDetails',
components: {
Description,
Multiselect,
DatetimePicker,
Actions,
ActionButton,
Avatar,
CollectionList,
},
mixins: [Color],
props: {
card: {
type: Object,
default: null,
},
},
data() {
return {
saving: false,
assignedUsers: null,
addedLabelToCard: null,
copiedCard: null,
assignedLabels: null,
locale: getLocale(),
lang: {
days: getDayNamesMin(),
months: getMonthNamesShort(),
formatLocale: {
firstDayOfWeek: getFirstDay() === 0 ? 7 : getFirstDay(),
},
placeholder: {
date: t('deck', 'Select Date'),
},
},
format: {
stringify: this.stringify,
parse: this.parse,
},
}
},
computed: {
...mapState({
currentBoard: state => state.currentBoard,
cardDetailsInModal: state => state.cardDetailsInModal,
}),
...mapGetters(['canEdit', 'assignables']),
formatedAssignables() {
return this.assignables.map(item => {
const assignable = {
...item,
user: item.primaryKey,
displayName: item.displayname,
icon: 'icon-user',
isNoUser: false,
multiselectKey: item.type + ':' + item.uid,
}
if (item.type === 1) {
assignable.icon = 'icon-group'
assignable.isNoUser = true
}
if (item.type === 7) {
assignable.icon = 'icon-circles'
assignable.isNoUser = true
}
return assignable
})
},
duedate: {
get() {
return this.card.duedate ? new Date(this.card.duedate) : null
},
async set(val) {
this.saving = true
await this.$store.dispatch('updateCardDue', {
...this.copiedCard,
duedate: val ? (new Date(val)).toISOString() : null,
})
this.saving = false
},
},
labelsSorted() {
return [...this.currentBoard.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
},
watch: {
card() {
this.initialize()
},
},
mounted() {
this.initialize()
},
methods: {
async initialize() {
if (!this.card) {
return
}
this.copiedCard = JSON.parse(JSON.stringify(this.card))
this.assignedLabels = [...this.card.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
if (this.card.assignedUsers && this.card.assignedUsers.length > 0) {
this.assignedUsers = this.card.assignedUsers.map((item) => ({
...item.participant,
isNoUser: item.participant.type !== 0,
multiselectKey: item.participant.type + ':' + item.participant.primaryKey,
}))
} else {
this.assignedUsers = []
}
},
setDue() {
this.$store.dispatch('updateCardDue', this.copiedCard)
},
removeDue() {
this.copiedCard.duedate = null
this.$store.dispatch('updateCardDue', this.copiedCard)
},
assignUserToCard(user) {
this.$store.dispatch('assignCardToUser', {
card: this.copiedCard,
assignee: {
userId: user.uid,
type: user.type,
},
})
},
removeUserFromCard(user) {
this.$store.dispatch('removeUserFromCard', {
card: this.copiedCard,
assignee: {
userId: user.uid,
type: user.type,
},
})
},
addLabelToCard(newLabel) {
this.copiedCard.labels.push(newLabel)
const data = {
card: this.copiedCard,
labelId: newLabel.id,
}
this.$store.dispatch('addLabel', data)
},
removeLabelFromCard(removedLabel) {
const removeIndex = this.copiedCard.labels.findIndex((label) => {
return label.id === removedLabel.id
})
if (removeIndex !== -1) {
this.copiedCard.labels.splice(removeIndex, 1)
}
const data = {
card: this.copiedCard,
labelId: removedLabel.id,
}
this.$store.dispatch('removeLabel', data)
},
stringify(date) {
return moment(date).locale(this.locale).format('LLL')
},
parse(value) {
return moment(value).toDate()
},
},
}
</script>
<style lang="scss" scoped>
.section-wrapper {
display: flex;
max-width: 100%;
margin-top: 10px;
.section-label {
background-position: 0px center;
width: 28px;
margin-left: 9px;
flex-shrink: 0;
}
.section-details {
flex-grow: 1;
button.action-item--single {
margin-top: -6px;
}
}
}
.tag {
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
padding: 0px 5px;
border-radius: 15px;
font-size: 85%;
margin-right: 3px;
}
.avatarLabel {
padding: 6px
}
.section-details::v-deep .multiselect__tags-wrap {
flex-wrap: wrap;
}
.avatar-list--readonly .avatardiv {
margin-right: 3px;
}
.avatarlist--inline {
display: flex;
align-items: center;
margin-right: 3px;
.avatarLabel {
padding: 0;
}
}
.multiselect::v-deep .multiselect__tags-wrap {
z-index: 2;
}
.multiselect.multiselect--active::v-deep .multiselect__tags-wrap {
z-index: 0;
}
</style>

View File

@@ -1,292 +0,0 @@
<!--
- @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
-
- @author Julius Härtl <jus@bitgrid.net>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div>
<h5>
{{ t('deck', 'Description') }}
<span v-if="descriptionLastEdit && !descriptionSaving">{{ t('deck', '(Unsaved)') }}</span>
<span v-if="descriptionSaving">{{ t('deck', '(Saving)') }}</span>
<a v-tooltip="t('deck', 'Formatting help')"
href="https://deck.readthedocs.io/en/latest/Markdown/"
target="_blank"
class="icon icon-info" />
<Actions v-if="canEdit">
<ActionButton v-if="!descriptionEditing" icon="icon-rename" @click="showEditor()">
{{ t('deck', 'Edit description') }}
</ActionButton>
<ActionButton v-else icon="icon-toggle" @click="hideEditor()">
{{ t('deck', 'View description') }}
</ActionButton>
</Actions>
<Actions v-if="canEdit">
<ActionButton v-if="descriptionEditing" icon="icon-attach" @click="showAttachmentModal()">
{{ t('deck', 'Add Attachment') }}
</ActionButton>
</Actions>
</h5>
<div v-if="!descriptionEditing"
id="description-preview"
@click="clickedPreview"
v-html="renderedDescription" />
<VueEasymde v-else
:key="card.id"
ref="markdownEditor"
v-model="description"
:configs="mdeConfig"
@input="updateDescription"
@blur="saveDescription" />
<Modal v-if="modalShow" :title="t('deck', 'Choose attachment')" @close="modalShow=false">
<div class="modal__content">
<h3>{{ t('deck', 'Choose attachment') }}</h3>
<AttachmentList
:card-id="card.id"
:selectable="true"
@selectAttachment="addAttachment" />
</div>
</Modal>
</div>
</template>
<script>
import MarkdownIt from 'markdown-it'
import MarkdownItTaskLists from 'markdown-it-task-lists'
import AttachmentList from './AttachmentList'
import { Actions, ActionButton, Modal } from '@nextcloud/vue'
import { formatFileSize } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import { mapState, mapGetters } from 'vuex'
const markdownIt = new MarkdownIt({
linkify: true,
})
markdownIt.use(MarkdownItTaskLists, { enabled: true, label: true, labelAfter: true })
export default {
name: 'Description',
components: {
VueEasymde: () => import('vue-easymde/dist/VueEasyMDE.common'),
Actions,
ActionButton,
Modal,
AttachmentList,
},
props: {
card: {
type: Object,
default: null,
},
},
data() {
return {
description: '',
markdownIt: null,
descriptionEditing: false,
mdeConfig: {
autoDownloadFontAwesome: false,
spellChecker: false,
autofocus: true,
autosave: { enabled: false, uniqueId: 'unique' },
toolbar: false,
},
descriptionSaveTimeout: null,
descriptionSaving: false,
descriptionLastEdit: 0,
modalShow: false,
}
},
computed: {
...mapState({
currentBoard: state => state.currentBoard,
cardDetailsInModal: state => state.cardDetailsInModal,
}),
...mapGetters(['canEdit']),
attachments() {
return [...this.$store.getters.attachmentsByCard(this.id)].sort((a, b) => b.id - a.id)
},
mimetypeForAttachment() {
return (mimetype) => {
const url = OC.MimeType.getIconUrl(mimetype)
const styles = {
'background-image': `url("${url}")`,
}
return styles
}
},
attachmentUrl() {
return (attachment) => generateUrl(`/apps/deck/cards/${attachment.cardId}/attachment/${attachment.id}`)
},
formattedFileSize() {
return (filesize) => formatFileSize(filesize)
},
renderedDescription() {
return markdownIt.render(this.card.description || '')
},
},
methods: {
showEditor() {
if (!this.canEdit) {
return
}
this.descriptionEditing = true
this.description = this.card.description
},
hideEditor() {
this.descriptionEditing = false
},
showAttachmentModal() {
this.modalShow = true
},
addAttachment(attachment) {
const descString = this.$refs.markdownEditor.easymde.value()
let embed = ''
if (attachment.extendedData.mimetype.includes('image')) {
embed = '!'
}
const attachmentString = embed + '[📎 ' + attachment.data + '](' + this.attachmentUrl(attachment) + ')'
this.$refs.markdownEditor.easymde.value(descString + '\n' + attachmentString)
this.modalShow = false
},
clickedPreview(e) {
if (e.target.getAttribute('type') === 'checkbox') {
const clickedIndex = [...document.querySelector('#description-preview').querySelectorAll('input')].findIndex((li) => li.id === e.target.id)
const reg = /\[(X|\s|_|-)\]/ig
let nth = 0
const updatedDescription = this.description.replace(reg, (match, i, original) => {
let result = match
if ('' + nth++ === '' + clickedIndex) {
if (match.match(/^\[\s\]/i)) {
result = match.replace(/\[\s\]/i, '[x]')
}
if (match.match(/^\[x\]/i)) {
result = match.replace(/\[x\]/i, '[ ]')
}
return result
}
return match
})
this.$store.dispatch('updateCardDesc', { ...this.card, description: updatedDescription })
}
},
async saveDescription() {
if (this.descriptionLastEdit === 0 || this.descriptionSaving) {
return
}
this.descriptionSaving = true
await this.$store.dispatch('updateCardDesc', { ...this.card, description: this.description })
this.descriptionLastEdit = 0
this.descriptionSaving = false
},
updateDescription() {
this.descriptionLastEdit = Date.now()
clearTimeout(this.descriptionSaveTimeout)
this.descriptionSaveTimeout = setTimeout(async() => {
await this.saveDescription()
}, 2500)
},
},
}
</script>
<style lang="scss" scoped>
.modal__content {
width: 25vw;
min-width: 250px;
min-height: 120px;
margin: 20px;
padding-bottom: 20px;
display: flex;
flex-direction: column;
&::v-deep .attachment-list {
flex-shrink: 1;
overflow: scroll;
max-height: 50vh;
}
}
#description-preview {
min-height: 100px;
&::v-deep {
@import './../../css/markdown';
}
&::v-deep input {
min-height: auto;
}
&::v-deep a {
text-decoration: underline;
}
}
h5 {
border-bottom: 1px solid var(--color-border);
margin-top: 20px;
margin-bottom: 5px;
color: var(--color-text-maxcontrast);
.icon-info {
display: inline-block;
width: 32px;
height: 16px;
float: right;
opacity: .7;
}
.icon-attach {
background-size: 16px;
float: right;
margin-top: -14px;
opacity: .7;
}
.icon-toggle, .icon-rename {
float: right;
margin-top: -14px;
}
}
</style>
<style>
@import '~easymde/dist/easymde.min.css';
.vue-easymde, .CodeMirror {
border: none;
margin: 0;
padding: 0;
background-color: var(--color-main-background);
color: var(--color-main-text);
}
.editor-preview,
.editor-statusbar {
display: none;
}
#app-sidebar .app-sidebar-header__desc h4 {
font-size: 12px !important;
}
</style>

View File

@@ -131,6 +131,13 @@ export default {
return [...this.card.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
},
watch: {
currentCard(newValue) {
if (newValue) {
this.$nextTick(() => this.$el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }))
}
},
},
methods: {
openCard() {
const boardId = this.card && this.card.boardId ? this.card.boardId : this.$route.params.id
@@ -160,10 +167,6 @@ export default {
@import './../../css/animations';
@import './../../css/variables';
.card:hover {
box-shadow: 0 0 5px 1px var(--color-box-shadow);
}
.card {
transition: box-shadow 0.1s ease-in-out;
box-shadow: 0 0 2px 0 var(--color-box-shadow);
@@ -180,9 +183,12 @@ export default {
border: 2px solid var(--color-border);
}
&.current-card {
&:hover {
box-shadow: 0 0 5px 0 var(--color-box-shadow);
}
&.current-card {
box-shadow: 0 0 5px 1px var(--color-box-shadow);
}
.card-upper {
display: flex;

View File

@@ -24,43 +24,22 @@
<div v-if="card">
<div @click.stop.prevent>
<Actions v-if="canEdit && !isArchived">
<ActionButton v-if="showArchived === false && !isCurrentUserAssigned"
icon="icon-user"
:close-after-click="true"
@click="assignCardToMe()">
<ActionButton v-if="showArchived === false && !isCurrentUserAssigned" icon="icon-user" @click="assignCardToMe()">
{{ t('deck', 'Assign to me') }}
</ActionButton>
<ActionButton v-if="showArchived === false && isCurrentUserAssigned"
icon="icon-user"
:close-after-click="true"
@click="unassignCardFromMe()">
<ActionButton v-if="showArchived === false && isCurrentUserAssigned" icon="icon-user" @click="unassignCardFromMe()">
{{ t('deck', 'Unassign myself') }}
</ActionButton>
<ActionButton v-if="showArchived === false && card.duedate && !card.dueDone"
icon="icon-checkmark"
:close-after-click="true"
@click="toggleDoneState(true)">
{{ t('deck', 'Mark card as done') }}
</ActionButton>
<ActionButton v-if="showArchived === false && card.duedate && card.dueDone"
icon="icon-checkmark"
:close-after-click="true"
@click="toggleDoneState(false)">
{{ t('deck', 'Mark card as pending') }}
</ActionButton>
<ActionButton icon="icon-external" :close-after-click="true" @click.stop="modalShow=true">
<ActionButton icon="icon-external" @click.stop="modalShow=true">
{{ t('deck', 'Move card') }}
</ActionButton>
<ActionButton icon="icon-settings-dark" :close-after-click="true" @click="openCard">
<ActionButton icon="icon-settings-dark" @click="openCard">
{{ t('deck', 'Card details') }}
</ActionButton>
<ActionButton icon="icon-archive" :close-after-click="true" @click="archiveUnarchiveCard()">
<ActionButton icon="icon-archive" @click="archiveUnarchiveCard()">
{{ showArchived ? t('deck', 'Unarchive card') : t('deck', 'Archive card') }}
</ActionButton>
<ActionButton v-if="showArchived === false"
icon="icon-delete"
:close-after-click="true"
@click="deleteCard()">
<ActionButton v-if="showArchived === false" icon="icon-delete" @click="deleteCard()">
{{ t('deck', 'Delete card') }}
</ActionButton>
</Actions>
@@ -175,9 +154,6 @@ export default {
},
})
},
toggleDoneState(state) {
this.$store.dispatch('updateCardDueDone', { ...this.card, dueDone: state })
},
moveCard() {
this.copiedCard = Object.assign({}, this.card)
this.copiedCard.stackId = this.selectedStack.id

View File

@@ -23,8 +23,8 @@
<template>
<div v-if="card" class="duedate">
<transition name="zoom">
<div v-if="card.duedate" v-tooltip="card.dueDone ? relativeDate : null" :class="dueIcon">
<span v-if="!card.dueDone">{{ relativeDate }}</span>
<div v-if="card.duedate" :class="dueIcon">
<span>{{ relativeDate }}</span>
</div>
</transition>
</div>
@@ -45,15 +45,15 @@ export default {
dueIcon() {
const days = Math.floor(moment(this.card.duedate).diff(this.$root.time, 'seconds') / 60 / 60 / 24)
if (days < 0) {
return `due icon ${!this.card.dueDone ? 'icon-calendar overdue' : 'icon-checkmark'}`
return 'icon-calendar due icon overdue'
}
if (days === 0) {
return `due icon ${!this.card.dueDone ? 'icon-calendar-dark now' : 'icon-checkmark'}`
return 'icon-calendar-dark due icon now'
}
if (days === 1) {
return `due icon ${!this.card.dueDone ? 'con-calendar-dark next' : 'icon-checkmark'}`
return 'icon-calendar-dark due icon next'
}
return `due icon ${!this.card.dueDone ? 'icon-calendar-dark' : 'icon-checkmark'}`
return 'icon-calendar-dark due icon'
},
relativeDate() {
const diff = moment(this.$root.time).diff(this.card.duedate, 'seconds')
@@ -88,10 +88,6 @@ export default {
background-size: contain;
}
&.icon-checkmark {
width: 20px;
}
&.overdue {
background-color: var(--color-error);
color: var(--color-primary-text);

View File

@@ -236,9 +236,5 @@ export default {
const updatedCard = await apiClient.updateCard(card)
commit('updateCardProperty', { property: 'duedate', card: updatedCard })
},
async updateCardDueDone({ commit }, card) {
const updatedCard = await apiClient.updateCard(card)
commit('updateCardProperty', { property: 'dueDone', card: updatedCard })
},
},
}