Skip to content

Commit 0435c48

Browse files
feat: add board import and export
Signed-off-by: Luka Trovic <luka@nextcloud.com>
1 parent b0fbb38 commit 0435c48

10 files changed

Lines changed: 433 additions & 2 deletions

File tree

appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
['name' => 'board#clone', 'url' => '/boards/{boardId}/clone', 'verb' => 'POST'],
3030
['name' => 'board#transferOwner', 'url' => '/boards/{boardId}/transferOwner', 'verb' => 'PUT'],
3131
['name' => 'board#export', 'url' => '/boards/{boardId}/export', 'verb' => 'GET'],
32+
['name' => 'board#import', 'url' => '/boards/import', 'verb' => 'POST'],
3233

3334
// stacks
3435
['name' => 'stack#index', 'url' => '/stacks/{boardId}', 'verb' => 'GET'],

cypress/e2e/boardFeatures.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,81 @@ describe('Board cloning', function() {
129129
})
130130
})
131131
})
132+
133+
describe('Board export', function() {
134+
before(function() {
135+
cy.createUser(user)
136+
})
137+
138+
it('Exports a board as JSON', function() {
139+
const boardName = 'Export JSON board'
140+
const board = sampleBoard(boardName)
141+
cy.createExampleBoard({ user, board }).then((board) => {
142+
const boardId = board.id
143+
cy.visit(`/apps/deck/board/${boardId}`)
144+
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
145+
.parent()
146+
.find('button[aria-label="Actions"]')
147+
.click()
148+
cy.get('button:contains("Export board")')
149+
.click()
150+
cy.get('.modal-container .checkbox-radio-switch__text:contains("Export as JSON")')
151+
.click()
152+
cy.get('.modal-container button:contains("Export")')
153+
.click()
154+
155+
const downloadsFolder = Cypress.config('downloadsFolder')
156+
cy.readFile(`${downloadsFolder}/${boardName}.json`)
157+
})
158+
})
159+
160+
it('Exports a board as CSV', function() {
161+
const boardName = 'Export CSV board'
162+
const board = sampleBoard(boardName)
163+
cy.createExampleBoard({ user, board }).then((board) => {
164+
const boardId = board.id
165+
cy.visit(`/apps/deck/board/${boardId}`)
166+
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
167+
.parent()
168+
.find('button[aria-label="Actions"]')
169+
.click()
170+
cy.get('button:contains("Export board")')
171+
.click()
172+
cy.get('.modal-container .checkbox-radio-switch__text:contains("Export as CSV")')
173+
.click()
174+
cy.get('.modal-container button:contains("Export")')
175+
.click()
176+
177+
const downloadsFolder = Cypress.config('downloadsFolder')
178+
cy.readFile(`${downloadsFolder}/${boardName}.csv`)
179+
})
180+
})
181+
})
182+
183+
describe('Board import', function() {
184+
before(function () {
185+
cy.createUser(user)
186+
})
187+
beforeEach(function() {
188+
cy.login(user)
189+
cy.visit('/apps/deck')
190+
})
191+
192+
it('Imports a board from JSON', function() {
193+
cy.get('#app-navigation-vue .app-navigation__list .app-navigation-entry:contains("Import board")')
194+
.should('be.visible')
195+
.click()
196+
197+
// Upload a JSON file
198+
cy.get('input[type="file"]')
199+
.selectFile([
200+
{
201+
contents: 'cypress/fixtures/import-board.json',
202+
fileName: 'import-board.json',
203+
},
204+
], { force: true })
205+
206+
cy.get('.app-navigation__list .app-navigation-entry:contains("Imported board")')
207+
.should('be.visible')
208+
})
209+
})

cypress/fixtures/import-board.json

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
{
2+
"boards": [
3+
{
4+
"id": 70,
5+
"title": "Imported board",
6+
"owner": "unvjrmwuag",
7+
"color": "00ff00",
8+
"archived": false,
9+
"labels": [
10+
{
11+
"id": 293,
12+
"title": "Finished",
13+
"color": "31CC7C",
14+
"boardId": 70,
15+
"cardId": null,
16+
"lastModified": 0,
17+
"ETag": "cfcd208495d565ef66e7dff9f98764da"
18+
},
19+
{
20+
"id": 294,
21+
"title": "To review",
22+
"color": "317CCC",
23+
"boardId": 70,
24+
"cardId": null,
25+
"lastModified": 0,
26+
"ETag": "cfcd208495d565ef66e7dff9f98764da"
27+
},
28+
{
29+
"id": 295,
30+
"title": "Action needed",
31+
"color": "FF7A66",
32+
"boardId": 70,
33+
"cardId": null,
34+
"lastModified": 0,
35+
"ETag": "cfcd208495d565ef66e7dff9f98764da"
36+
},
37+
{
38+
"id": 296,
39+
"title": "Later",
40+
"color": "F1DB50",
41+
"boardId": 70,
42+
"cardId": null,
43+
"lastModified": 0,
44+
"ETag": "cfcd208495d565ef66e7dff9f98764da"
45+
}
46+
],
47+
"acl": [],
48+
"permissions": [],
49+
"users": [],
50+
"stacks": {
51+
"114": {
52+
"id": 114,
53+
"title": "TestList",
54+
"boardId": 70,
55+
"deletedAt": 0,
56+
"lastModified": 1743495533,
57+
"cards": [
58+
{
59+
"id": 124,
60+
"title": "Hello world",
61+
"description": "# Hello world",
62+
"descriptionPrev": null,
63+
"stackId": 114,
64+
"type": "plain",
65+
"lastModified": 1743495533,
66+
"lastEditor": null,
67+
"createdAt": 1743495533,
68+
"labels": [],
69+
"assignedUsers": null,
70+
"attachments": null,
71+
"attachmentCount": null,
72+
"owner": {
73+
"primaryKey": "unvjrmwuag",
74+
"uid": "unvjrmwuag",
75+
"displayname": "unvjrmwuag",
76+
"type": 0
77+
},
78+
"order": 999,
79+
"archived": false,
80+
"done": null,
81+
"duedate": null,
82+
"notified": false,
83+
"deletedAt": 0,
84+
"commentsUnread": 0,
85+
"commentsCount": 0,
86+
"relatedStack": null,
87+
"relatedBoard": null,
88+
"ETag": "aa85bb973089e7fbc0bbf122e926c23f"
89+
}
90+
],
91+
"order": 0,
92+
"ETag": "aa85bb973089e7fbc0bbf122e926c23f"
93+
}
94+
},
95+
"activeSessions": [],
96+
"deletedAt": 0,
97+
"lastModified": 1743495533,
98+
"settings": [],
99+
"ETag": "aa85bb973089e7fbc0bbf122e926c23f"
100+
}
101+
]
102+
}

lib/Controller/BoardController.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
use OCA\Deck\Db\Acl;
1111
use OCA\Deck\Db\Board;
1212
use OCA\Deck\Service\BoardService;
13+
use OCA\Deck\Service\Importer\BoardImportService;
1314
use OCA\Deck\Service\PermissionService;
1415
use OCP\AppFramework\ApiController;
1516
use OCP\AppFramework\Http;
1617
use OCP\AppFramework\Http\DataResponse;
18+
use OCP\IL10N;
1719
use OCP\IRequest;
1820

1921
class BoardController extends ApiController {
@@ -22,6 +24,8 @@ public function __construct(
2224
IRequest $request,
2325
private BoardService $boardService,
2426
private PermissionService $permissionService,
27+
private BoardImportService $boardImportService,
28+
private IL10N $l10n,
2529
private $userId,
2630
) {
2731
parent::__construct($appName, $request);
@@ -163,4 +167,50 @@ public function transferOwner(int $boardId, string $newOwner): DataResponse {
163167
public function export($boardId) {
164168
return $this->boardService->export($boardId);
165169
}
170+
171+
/**
172+
* @NoAdminRequired
173+
*/
174+
public function import(): DataResponse {
175+
$file = $this->request->getUploadedFile('file');
176+
$error = null;
177+
$phpFileUploadErrors = [
178+
UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
179+
UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
180+
UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
181+
UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
182+
UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
183+
UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
184+
UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
185+
UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
186+
];
187+
188+
if (empty($file)) {
189+
$error = $this->l10n->t('No file uploaded or file size exceeds maximum of %s', [\OCP\Util::humanFileSize(\OCP\Util::uploadLimit())]);
190+
}
191+
if (!empty($file) && array_key_exists('error', $file) && $file['error'] !== UPLOAD_ERR_OK) {
192+
$error = $phpFileUploadErrors[$file['error']];
193+
}
194+
if (!empty($file) && $file['error'] === UPLOAD_ERR_OK && !in_array($file['type'], ['application/json', 'text/plain'])) {
195+
$error = $this->l10n->t('Invalid file type. Only JSON files are allowed.');
196+
}
197+
if ($error !== null) {
198+
return new DataResponse([
199+
'status' => 'error',
200+
'message' => $error,
201+
], Http::STATUS_BAD_REQUEST);
202+
}
203+
204+
$fileContent = file_get_contents($file['tmp_name']);
205+
$this->boardImportService->setSystem('DeckJson');
206+
$config = new \stdClass();
207+
$config->owner = $this->userId;
208+
$this->boardImportService->setConfigInstance($config);
209+
$this->boardImportService->setData(json_decode($fileContent));
210+
$this->boardImportService->import();
211+
$importedBoard = $this->boardImportService->getBoard();
212+
$board = $this->boardService->find($importedBoard->getId());
213+
214+
return new DataResponse($board, Http::STATUS_OK);
215+
}
166216
}

src/components/navigation/AppNavigation.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
</template>
4343
</AppNavigationBoardCategory>
4444
<AppNavigationAddBoard v-if="canCreate" />
45+
<AppNavigationImportBoard v-if="canCreate" />
4546
</template>
4647
<template #footer>
4748
<NcAppNavigationSettings :name="t('deck', 'Deck settings')">
@@ -116,6 +117,7 @@ import DeckIcon from './../icons/DeckIcon.vue'
116117
import ShareVariantIcon from 'vue-material-design-icons/Share.vue'
117118
import HelpModal from './../modals/HelpModal.vue'
118119
import { subscribe } from '@nextcloud/event-bus'
120+
import AppNavigationImportBoard from './AppNavigationImportBoard.vue'
119121
120122
const canCreateState = loadState('deck', 'canCreate')
121123
@@ -127,6 +129,7 @@ export default {
127129
NcButton,
128130
AppNavigationAddBoard,
129131
AppNavigationBoardCategory,
132+
AppNavigationImportBoard,
130133
NcSelect,
131134
NcAppNavigationItem,
132135
ArchiveIcon,

src/components/navigation/AppNavigationBoard.vue

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
<template #icon>
1616
<NcAppNavigationIconBullet :color="board.color" />
1717
<BoardCloneModal v-if="cloneModalOpen" :board-title="board.title" @close="onCloseCloneModal" />
18+
<BoardExportModal v-if="exportModalOpen"
19+
:board-title="board.title"
20+
@export="onExportBoard"
21+
@close="onCloseExportBoard" />
1822
</template>
1923

2024
<template #counter>
@@ -161,6 +165,8 @@ import { emit } from '@nextcloud/event-bus'
161165
162166
import isTouchDevice from '../../mixins/isTouchDevice.js'
163167
import BoardCloneModal from './BoardCloneModal.vue'
168+
import BoardExportModal from './BoardExportModal.vue'
169+
import { showLoading } from '@nextcloud/dialogs'
164170
165171
const canCreateState = loadState('deck', 'canCreate')
166172
@@ -179,6 +185,7 @@ export default {
179185
CloseIcon,
180186
CheckIcon,
181187
BoardCloneModal,
188+
BoardExportModal,
182189
},
183190
directives: {
184191
ClickOutside,
@@ -207,6 +214,7 @@ export default {
207214
updateDueSetting: null,
208215
canCreate: canCreateState,
209216
cloneModalOpen: false,
217+
exportModalOpen: false,
210218
}
211219
},
212220
computed: {
@@ -346,7 +354,16 @@ export default {
346354
this.updateDueSetting = null
347355
},
348356
actionExport() {
349-
this.boardApi.exportBoard(this.board)
357+
this.exportModalOpen = true
358+
},
359+
async onExportBoard(format) {
360+
this.exportModalOpen = false
361+
const loadingToast = showLoading(t('deck', 'Exporting board...'))
362+
await this.boardApi.exportBoard(this.board, format)
363+
loadingToast.hideToast()
364+
},
365+
onCloseExportBoard() {
366+
this.exportModalOpen = false
350367
},
351368
onNavigate() {
352369
if (this.isTouchDevice) {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
<template>
6+
<div>
7+
<NcAppNavigationItem :name="t('deck', 'Import board')" icon="icon-upload" @click.prevent.stop="startImportBoard" />
8+
<input ref="fileInput"
9+
type="file"
10+
accept="application/json"
11+
style="display: none;"
12+
@change="doImportBoard">
13+
</div>
14+
</template>
15+
16+
<script>
17+
import { NcAppNavigationItem } from '@nextcloud/vue'
18+
import { showError } from '../../helpers/errors.js'
19+
import { showSuccess, showLoading } from '@nextcloud/dialogs'
20+
21+
export default {
22+
name: 'AppNavigationImportBoard',
23+
components: { NcAppNavigationItem },
24+
props: {
25+
loading: {
26+
type: Boolean,
27+
default: false,
28+
},
29+
},
30+
data() {
31+
return {
32+
value: '',
33+
}
34+
},
35+
methods: {
36+
startImportBoard() {
37+
this.$refs.fileInput.value = ''
38+
this.$refs.fileInput.click()
39+
},
40+
async doImportBoard(event) {
41+
const file = event.target.files[0]
42+
if (file) {
43+
const loadingToast = showLoading(t('deck', 'Importing board...'))
44+
const result = await this.$store.dispatch('importBoard', file)
45+
loadingToast.hideToast()
46+
if (result?.message) {
47+
showError(result)
48+
} else {
49+
showSuccess(t('deck', 'Board imported successfully'))
50+
}
51+
}
52+
},
53+
},
54+
}
55+
</script>

0 commit comments

Comments
 (0)