Skip to content

Commit 49d7bf5

Browse files
authored
Merge pull request #8566 from nextcloud/backport/6872/stable30
[stable30] feat: add board import and export
2 parents 7abf86b + 423bd7e commit 49d7bf5

12 files changed

Lines changed: 467 additions & 4 deletions

File tree

‎appinfo/routes.php‎

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

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

‎cypress/e2e/boardFeatures.js‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55
import { randUser } from '../utils/index.js'
6+
import { sampleBoard } from '../utils/sampleBoard.js'
67
const user = randUser()
78
const recipient = randUser()
89

@@ -58,3 +59,81 @@ describe('Board', function() {
5859
.should('be.visible')
5960
})
6061
})
62+
63+
describe('Board export', function() {
64+
before(function() {
65+
cy.createUser(user)
66+
})
67+
68+
it('Exports a board as JSON', function() {
69+
const boardName = 'Export JSON board'
70+
const board = sampleBoard(boardName)
71+
cy.createExampleBoard({ user, board }).then((board) => {
72+
const boardId = board.id
73+
cy.visit(`/apps/deck/board/${boardId}`)
74+
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
75+
.parent()
76+
.find('button[aria-label="Actions"]')
77+
.click()
78+
cy.get('button:contains("Export board")')
79+
.click()
80+
cy.get('.modal-container .checkbox-radio-switch__text:contains("Export as JSON")')
81+
.click()
82+
cy.get('.modal-container button:contains("Export")')
83+
.click()
84+
85+
const downloadsFolder = Cypress.config('downloadsFolder')
86+
cy.readFile(`${downloadsFolder}/${boardName}.json`)
87+
})
88+
})
89+
90+
it('Exports a board as CSV', function() {
91+
const boardName = 'Export CSV board'
92+
const board = sampleBoard(boardName)
93+
cy.createExampleBoard({ user, board }).then((board) => {
94+
const boardId = board.id
95+
cy.visit(`/apps/deck/board/${boardId}`)
96+
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
97+
.parent()
98+
.find('button[aria-label="Actions"]')
99+
.click()
100+
cy.get('button:contains("Export board")')
101+
.click()
102+
cy.get('.modal-container .checkbox-radio-switch__text:contains("Export as CSV")')
103+
.click()
104+
cy.get('.modal-container button:contains("Export")')
105+
.click()
106+
107+
const downloadsFolder = Cypress.config('downloadsFolder')
108+
cy.readFile(`${downloadsFolder}/${boardName}.csv`)
109+
})
110+
})
111+
})
112+
113+
describe('Board import', function() {
114+
before(function() {
115+
cy.createUser(user)
116+
})
117+
beforeEach(function() {
118+
cy.login(user)
119+
cy.visit('/apps/deck')
120+
})
121+
122+
it('Imports a board from JSON', function() {
123+
cy.get('#app-navigation-vue .app-navigation__list .app-navigation-entry:contains("Import board")')
124+
.should('be.visible')
125+
.click()
126+
127+
// Upload a JSON file
128+
cy.get('input[type="file"]')
129+
.selectFile([
130+
{
131+
contents: 'cypress/fixtures/import-board.json',
132+
fileName: 'import-board.json',
133+
},
134+
], { force: true })
135+
136+
cy.get('.app-navigation__list .app-navigation-entry:contains("Imported board")')
137+
.should('be.visible')
138+
})
139+
})

‎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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
use OCA\Deck\Db\Acl;
1010
use OCA\Deck\Db\Board;
1111
use OCA\Deck\Service\BoardService;
12+
use OCA\Deck\Service\Importer\BoardImportService;
1213
use OCA\Deck\Service\PermissionService;
1314
use OCP\AppFramework\ApiController;
1415
use OCP\AppFramework\Http;
1516
use OCP\AppFramework\Http\DataResponse;
17+
use OCP\IL10N;
1618
use OCP\IRequest;
1719

1820
class BoardController extends ApiController {
@@ -21,6 +23,8 @@ public function __construct(
2123
IRequest $request,
2224
private BoardService $boardService,
2325
private PermissionService $permissionService,
26+
private BoardImportService $boardImportService,
27+
private IL10N $l10n,
2428
private $userId,
2529
) {
2630
parent::__construct($appName, $request);
@@ -162,4 +166,62 @@ public function transferOwner(int $boardId, string $newOwner): DataResponse {
162166
public function export($boardId) {
163167
return $this->boardService->export($boardId);
164168
}
169+
170+
/**
171+
* @NoAdminRequired
172+
*/
173+
public function import(): DataResponse {
174+
$file = $this->request->getUploadedFile('file');
175+
$error = null;
176+
$phpFileUploadErrors = [
177+
UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
178+
UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
179+
UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
180+
UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
181+
UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
182+
UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
183+
UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
184+
UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
185+
];
186+
187+
if (empty($file)) {
188+
$error = $this->l10n->t('No file uploaded or file size exceeds maximum of %s', [\OCP\Util::humanFileSize(\OCP\Util::uploadLimit())]);
189+
}
190+
if (!empty($file) && array_key_exists('error', $file) && $file['error'] !== UPLOAD_ERR_OK) {
191+
$error = $phpFileUploadErrors[$file['error']];
192+
}
193+
if (!empty($file) && $file['error'] === UPLOAD_ERR_OK && !in_array($file['type'], ['application/json', 'text/plain'])) {
194+
$error = $this->l10n->t('Invalid file type. Only JSON files are allowed.');
195+
}
196+
if ($error !== null) {
197+
return new DataResponse([
198+
'status' => 'error',
199+
'message' => $error,
200+
], Http::STATUS_BAD_REQUEST);
201+
}
202+
203+
try {
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+
} catch (\TypeError $e) {
216+
return new DataResponse([
217+
'status' => 'error',
218+
'message' => $this->l10n->t('Invalid JSON data'),
219+
], Http::STATUS_BAD_REQUEST);
220+
} catch (\Exception $e) {
221+
return new DataResponse([
222+
'status' => 'error',
223+
'message' => $this->l10n->t('Failed to import board'),
224+
], Http::STATUS_BAD_REQUEST);
225+
}
226+
}
165227
}

‎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: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
:force-display-actions="isTouchDevice"
1313
@click="onNavigate"
1414
@undo="unDelete">
15-
<NcAppNavigationIconBullet slot="icon" :color="board.color" />
15+
<template #icon>
16+
<NcAppNavigationIconBullet :color="board.color" />
17+
<BoardExportModal v-if="exportModalOpen"
18+
:board-title="board.title"
19+
@export="onExportBoard"
20+
@close="onCloseExportBoard" />
21+
</template>
1622

1723
<template #counter>
1824
<AccountIcon v-if="board.acl.length > 0" />
@@ -157,6 +163,8 @@ import { loadState } from '@nextcloud/initial-state'
157163
import { emit } from '@nextcloud/event-bus'
158164
159165
import isTouchDevice from '../../mixins/isTouchDevice.js'
166+
import BoardExportModal from './BoardExportModal.vue'
167+
import { showMessage, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
160168
161169
const canCreateState = loadState('deck', 'canCreate')
162170
@@ -174,6 +182,7 @@ export default {
174182
CloneIcon,
175183
CloseIcon,
176184
CheckIcon,
185+
BoardExportModal,
177186
},
178187
directives: {
179188
ClickOutside,
@@ -201,6 +210,7 @@ export default {
201210
isDueSubmenuActive: false,
202211
updateDueSetting: null,
203212
canCreate: canCreateState,
213+
exportModalOpen: false,
204214
}
205215
},
206216
computed: {
@@ -340,7 +350,16 @@ export default {
340350
this.updateDueSetting = null
341351
},
342352
actionExport() {
343-
this.boardApi.exportBoard(this.board)
353+
this.exportModalOpen = true
354+
},
355+
async onExportBoard(format) {
356+
this.exportModalOpen = false
357+
const loadingToast = showMessage(t('deck', 'Exporting board...'), { timeout: TOAST_PERMANENT_TIMEOUT })
358+
await this.boardApi.exportBoard(this.board, format)
359+
loadingToast.hideToast()
360+
},
361+
onCloseExportBoard() {
362+
this.exportModalOpen = false
344363
},
345364
onNavigate() {
346365
if (this.isTouchDevice) {

0 commit comments

Comments
 (0)