Merge pull request #6872 from nextcloud/board-import-export

feat: add board import and export
This commit is contained in:
Luka Trovic
2025-04-30 22:41:09 +02:00
committed by GitHub
12 changed files with 463 additions and 3 deletions

View File

@@ -29,6 +29,7 @@ return [
['name' => 'board#clone', 'url' => '/boards/{boardId}/clone', 'verb' => 'POST'], ['name' => 'board#clone', 'url' => '/boards/{boardId}/clone', 'verb' => 'POST'],
['name' => 'board#transferOwner', 'url' => '/boards/{boardId}/transferOwner', 'verb' => 'PUT'], ['name' => 'board#transferOwner', 'url' => '/boards/{boardId}/transferOwner', 'verb' => 'PUT'],
['name' => 'board#export', 'url' => '/boards/{boardId}/export', 'verb' => 'GET'], ['name' => 'board#export', 'url' => '/boards/{boardId}/export', 'verb' => 'GET'],
['name' => 'board#import', 'url' => '/boards/import', 'verb' => 'POST'],
// stacks // stacks
['name' => 'stack#index', 'url' => '/stacks/{boardId}', 'verb' => 'GET'], ['name' => 'stack#index', 'url' => '/stacks/{boardId}', 'verb' => 'GET'],

View File

@@ -129,3 +129,81 @@ describe('Board cloning', function() {
}) })
}) })
}) })
describe('Board export', function() {
before(function() {
cy.createUser(user)
})
it('Exports a board as JSON', function() {
const boardName = 'Export JSON board'
const board = sampleBoard(boardName)
cy.createExampleBoard({ user, board }).then((board) => {
const boardId = board.id
cy.visit(`/apps/deck/board/${boardId}`)
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
.parent()
.find('button[aria-label="Actions"]')
.click()
cy.get('button:contains("Export board")')
.click()
cy.get('.modal-container .checkbox-radio-switch__text:contains("Export as JSON")')
.click()
cy.get('.modal-container button:contains("Export")')
.click()
const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/${boardName}.json`)
})
})
it('Exports a board as CSV', function() {
const boardName = 'Export CSV board'
const board = sampleBoard(boardName)
cy.createExampleBoard({ user, board }).then((board) => {
const boardId = board.id
cy.visit(`/apps/deck/board/${boardId}`)
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
.parent()
.find('button[aria-label="Actions"]')
.click()
cy.get('button:contains("Export board")')
.click()
cy.get('.modal-container .checkbox-radio-switch__text:contains("Export as CSV")')
.click()
cy.get('.modal-container button:contains("Export")')
.click()
const downloadsFolder = Cypress.config('downloadsFolder')
cy.readFile(`${downloadsFolder}/${boardName}.csv`)
})
})
})
describe('Board import', function() {
before(function () {
cy.createUser(user)
})
beforeEach(function() {
cy.login(user)
cy.visit('/apps/deck')
})
it('Imports a board from JSON', function() {
cy.get('#app-navigation-vue .app-navigation__list .app-navigation-entry:contains("Import board")')
.should('be.visible')
.click()
// Upload a JSON file
cy.get('input[type="file"]')
.selectFile([
{
contents: 'cypress/fixtures/import-board.json',
fileName: 'import-board.json',
},
], { force: true })
cy.get('.app-navigation__list .app-navigation-entry:contains("Imported board")')
.should('be.visible')
})
})

View File

@@ -0,0 +1,102 @@
{
"boards": [
{
"id": 70,
"title": "Imported board",
"owner": "unvjrmwuag",
"color": "00ff00",
"archived": false,
"labels": [
{
"id": 293,
"title": "Finished",
"color": "31CC7C",
"boardId": 70,
"cardId": null,
"lastModified": 0,
"ETag": "cfcd208495d565ef66e7dff9f98764da"
},
{
"id": 294,
"title": "To review",
"color": "317CCC",
"boardId": 70,
"cardId": null,
"lastModified": 0,
"ETag": "cfcd208495d565ef66e7dff9f98764da"
},
{
"id": 295,
"title": "Action needed",
"color": "FF7A66",
"boardId": 70,
"cardId": null,
"lastModified": 0,
"ETag": "cfcd208495d565ef66e7dff9f98764da"
},
{
"id": 296,
"title": "Later",
"color": "F1DB50",
"boardId": 70,
"cardId": null,
"lastModified": 0,
"ETag": "cfcd208495d565ef66e7dff9f98764da"
}
],
"acl": [],
"permissions": [],
"users": [],
"stacks": {
"114": {
"id": 114,
"title": "TestList",
"boardId": 70,
"deletedAt": 0,
"lastModified": 1743495533,
"cards": [
{
"id": 124,
"title": "Hello world",
"description": "# Hello world",
"descriptionPrev": null,
"stackId": 114,
"type": "plain",
"lastModified": 1743495533,
"lastEditor": null,
"createdAt": 1743495533,
"labels": [],
"assignedUsers": null,
"attachments": null,
"attachmentCount": null,
"owner": {
"primaryKey": "unvjrmwuag",
"uid": "unvjrmwuag",
"displayname": "unvjrmwuag",
"type": 0
},
"order": 999,
"archived": false,
"done": null,
"duedate": null,
"notified": false,
"deletedAt": 0,
"commentsUnread": 0,
"commentsCount": 0,
"relatedStack": null,
"relatedBoard": null,
"ETag": "aa85bb973089e7fbc0bbf122e926c23f"
}
],
"order": 0,
"ETag": "aa85bb973089e7fbc0bbf122e926c23f"
}
},
"activeSessions": [],
"deletedAt": 0,
"lastModified": 1743495533,
"settings": [],
"ETag": "aa85bb973089e7fbc0bbf122e926c23f"
}
]
}

View File

@@ -10,10 +10,12 @@ namespace OCA\Deck\Controller;
use OCA\Deck\Db\Acl; use OCA\Deck\Db\Acl;
use OCA\Deck\Db\Board; use OCA\Deck\Db\Board;
use OCA\Deck\Service\BoardService; use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\Importer\BoardImportService;
use OCA\Deck\Service\PermissionService; use OCA\Deck\Service\PermissionService;
use OCP\AppFramework\ApiController; use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\DataResponse;
use OCP\IL10N;
use OCP\IRequest; use OCP\IRequest;
class BoardController extends ApiController { class BoardController extends ApiController {
@@ -22,6 +24,8 @@ class BoardController extends ApiController {
IRequest $request, IRequest $request,
private BoardService $boardService, private BoardService $boardService,
private PermissionService $permissionService, private PermissionService $permissionService,
private BoardImportService $boardImportService,
private IL10N $l10n,
private $userId, private $userId,
) { ) {
parent::__construct($appName, $request); parent::__construct($appName, $request);
@@ -163,4 +167,62 @@ class BoardController extends ApiController {
public function export($boardId) { public function export($boardId) {
return $this->boardService->export($boardId); return $this->boardService->export($boardId);
} }
/**
* @NoAdminRequired
*/
public function import(): DataResponse {
$file = $this->request->getUploadedFile('file');
$error = null;
$phpFileUploadErrors = [
UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
];
if (empty($file)) {
$error = $this->l10n->t('No file uploaded or file size exceeds maximum of %s', [\OCP\Util::humanFileSize(\OCP\Util::uploadLimit())]);
}
if (!empty($file) && array_key_exists('error', $file) && $file['error'] !== UPLOAD_ERR_OK) {
$error = $phpFileUploadErrors[$file['error']];
}
if (!empty($file) && $file['error'] === UPLOAD_ERR_OK && !in_array($file['type'], ['application/json', 'text/plain'])) {
$error = $this->l10n->t('Invalid file type. Only JSON files are allowed.');
}
if ($error !== null) {
return new DataResponse([
'status' => 'error',
'message' => $error,
], Http::STATUS_BAD_REQUEST);
}
try {
$fileContent = file_get_contents($file['tmp_name']);
$this->boardImportService->setSystem('DeckJson');
$config = new \stdClass();
$config->owner = $this->userId;
$this->boardImportService->setConfigInstance($config);
$this->boardImportService->setData(json_decode($fileContent));
$this->boardImportService->import();
$importedBoard = $this->boardImportService->getBoard();
$board = $this->boardService->find($importedBoard->getId());
return new DataResponse($board, Http::STATUS_OK);
} catch (\TypeError $e) {
return new DataResponse([
'status' => 'error',
'message' => $this->l10n->t('Invalid JSON data'),
], Http::STATUS_BAD_REQUEST);
} catch (\Exception $e) {
return new DataResponse([
'status' => 'error',
'message' => $this->l10n->t('Failed to import board'),
], Http::STATUS_BAD_REQUEST);
}
}
} }

View File

@@ -42,6 +42,7 @@
</template> </template>
</AppNavigationBoardCategory> </AppNavigationBoardCategory>
<AppNavigationAddBoard v-if="canCreate" /> <AppNavigationAddBoard v-if="canCreate" />
<AppNavigationImportBoard v-if="canCreate" />
</template> </template>
<template #footer> <template #footer>
<NcAppNavigationSettings :name="t('deck', 'Deck settings')"> <NcAppNavigationSettings :name="t('deck', 'Deck settings')">
@@ -116,6 +117,7 @@ import DeckIcon from './../icons/DeckIcon.vue'
import ShareVariantIcon from 'vue-material-design-icons/Share.vue' import ShareVariantIcon from 'vue-material-design-icons/Share.vue'
import HelpModal from './../modals/HelpModal.vue' import HelpModal from './../modals/HelpModal.vue'
import { subscribe } from '@nextcloud/event-bus' import { subscribe } from '@nextcloud/event-bus'
import AppNavigationImportBoard from './AppNavigationImportBoard.vue'
const canCreateState = loadState('deck', 'canCreate') const canCreateState = loadState('deck', 'canCreate')
@@ -127,6 +129,7 @@ export default {
NcButton, NcButton,
AppNavigationAddBoard, AppNavigationAddBoard,
AppNavigationBoardCategory, AppNavigationBoardCategory,
AppNavigationImportBoard,
NcSelect, NcSelect,
NcAppNavigationItem, NcAppNavigationItem,
ArchiveIcon, ArchiveIcon,

View File

@@ -15,6 +15,10 @@
<template #icon> <template #icon>
<NcAppNavigationIconBullet :color="board.color" /> <NcAppNavigationIconBullet :color="board.color" />
<BoardCloneModal v-if="cloneModalOpen" :board-title="board.title" @close="onCloseCloneModal" /> <BoardCloneModal v-if="cloneModalOpen" :board-title="board.title" @close="onCloseCloneModal" />
<BoardExportModal v-if="exportModalOpen"
:board-title="board.title"
@export="onExportBoard"
@close="onCloseExportBoard" />
</template> </template>
<template #counter> <template #counter>
@@ -161,6 +165,8 @@ import { emit } from '@nextcloud/event-bus'
import isTouchDevice from '../../mixins/isTouchDevice.js' import isTouchDevice from '../../mixins/isTouchDevice.js'
import BoardCloneModal from './BoardCloneModal.vue' import BoardCloneModal from './BoardCloneModal.vue'
import BoardExportModal from './BoardExportModal.vue'
import { showLoading } from '@nextcloud/dialogs'
const canCreateState = loadState('deck', 'canCreate') const canCreateState = loadState('deck', 'canCreate')
@@ -179,6 +185,7 @@ export default {
CloseIcon, CloseIcon,
CheckIcon, CheckIcon,
BoardCloneModal, BoardCloneModal,
BoardExportModal,
}, },
directives: { directives: {
ClickOutside, ClickOutside,
@@ -207,6 +214,7 @@ export default {
updateDueSetting: null, updateDueSetting: null,
canCreate: canCreateState, canCreate: canCreateState,
cloneModalOpen: false, cloneModalOpen: false,
exportModalOpen: false,
} }
}, },
computed: { computed: {
@@ -346,7 +354,16 @@ export default {
this.updateDueSetting = null this.updateDueSetting = null
}, },
actionExport() { actionExport() {
this.boardApi.exportBoard(this.board) this.exportModalOpen = true
},
async onExportBoard(format) {
this.exportModalOpen = false
const loadingToast = showLoading(t('deck', 'Exporting board...'))
await this.boardApi.exportBoard(this.board, format)
loadingToast.hideToast()
},
onCloseExportBoard() {
this.exportModalOpen = false
}, },
onNavigate() { onNavigate() {
if (this.isTouchDevice) { if (this.isTouchDevice) {

View File

@@ -0,0 +1,55 @@
<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>
<NcAppNavigationItem :name="t('deck', 'Import board')" icon="icon-upload" @click.prevent.stop="startImportBoard" />
<input ref="fileInput"
type="file"
accept="application/json"
style="display: none;"
@change="doImportBoard">
</div>
</template>
<script>
import { NcAppNavigationItem } from '@nextcloud/vue'
import { showError } from '../../helpers/errors.js'
import { showSuccess, showLoading } from '@nextcloud/dialogs'
export default {
name: 'AppNavigationImportBoard',
components: { NcAppNavigationItem },
props: {
loading: {
type: Boolean,
default: false,
},
},
data() {
return {
value: '',
}
},
methods: {
startImportBoard() {
this.$refs.fileInput.value = ''
this.$refs.fileInput.click()
},
async doImportBoard(event) {
const file = event.target.files[0]
if (file) {
const loadingToast = showLoading(t('deck', 'Importing board...'))
const result = await this.$store.dispatch('importBoard', file)
loadingToast.hideToast()
if (result?.message) {
showError(result)
} else {
showSuccess(t('deck', 'Board imported successfully'))
}
}
},
},
}
</script>

View File

@@ -0,0 +1,78 @@
<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog :name="t('deck', 'Export {boardTitle}', {boardTitle: boardTitle})" @update:open="close">
<div class="modal__content">
<NcCheckboxRadioSwitch :checked.sync="exportFormat"
value="json"
type="radio"
name="board_export_format">
{{ t('deck', 'Export as JSON') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked.sync="exportFormat"
value="csv"
type="radio"
name="board_export_format">
{{ t('deck', 'Export as CSV') }}
</NcCheckboxRadioSwitch>
<p class="note">
{{ t('deck', 'Note: Only the JSON format is supported for importing back into the Deck app.') }}
</p>
</div>
<template #actions>
<NcButton @click="close">
{{ t('deck', 'Cancel') }}
</NcButton>
<NcButton type="primary" @click="exportBoard">
{{ t('deck', 'Export') }}
</NcButton>
</template>
</NcDialog>
</template>
<script>
import { NcButton, NcCheckboxRadioSwitch, NcDialog } from '@nextcloud/vue'
export default {
name: 'BoardExportModal',
components: {
NcDialog,
NcCheckboxRadioSwitch,
NcButton,
},
props: {
boardTitle: {
type: String,
default: 'Board',
},
},
data() {
return {
exportFormat: 'json',
}
},
methods: {
exportBoard() {
this.$emit('export', this.exportFormat)
this.close()
},
close() {
this.$emit('close')
},
},
}
</script>
<style scoped>
.modal__content {
margin: 20px;
}
p.note {
margin-top: 10px;
}
</style>

View File

@@ -139,10 +139,29 @@ export class BoardApi {
} }
} }
exportBoard(board) { exportBoard(board, format) {
return axios.get(this.url(`/boards/${board.id}/export`)) return axios.get(this.url(`/boards/${board.id}/export`))
.then( .then(
(response) => { (response) => {
if (format === 'json') {
const exportData = {
boards: [response.data],
}
const stacks = {}
for (const stack of response.data.stacks) {
stacks[stack.id] = stack
}
exportData.boards[0].stacks = stacks
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = response.data.title + '.json'
a.click()
a.remove()
return Promise.resolve()
}
const fields = { title: t('deck', 'Card title'), description: t('deck', 'Description'), stackId: t('deck', 'List name'), labels: t('deck', 'Tags'), assignedUsers: t('deck', 'Assigned users'), duedate: t('deck', 'Due date'), createdAt: t('deck', 'Created'), lastModified: t('deck', 'Modified') } const fields = { title: t('deck', 'Card title'), description: t('deck', 'Description'), stackId: t('deck', 'List name'), labels: t('deck', 'Tags'), assignedUsers: t('deck', 'Assigned users'), duedate: t('deck', 'Due date'), createdAt: t('deck', 'Created'), lastModified: t('deck', 'Modified') }
let row = '' let row = ''
Object.keys(fields).forEach(field => { Object.keys(fields).forEach(field => {
@@ -215,6 +234,27 @@ export class BoardApi {
}) })
} }
importBoard(file) {
const formData = new FormData()
formData.append('file', file)
return axios.post(this.url('/boards/import'), formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
},
)
.catch((err) => {
return Promise.reject(err)
})
}
// Label API Calls // Label API Calls
deleteLabel(id) { deleteLabel(id) {
return axios.delete(this.url(`/labels/${id}`)) return axios.delete(this.url(`/labels/${id}`))

View File

@@ -398,6 +398,14 @@ export default new Vuex.Store({
return err return err
} }
}, },
async importBoard({ commit }, file) {
try {
const board = await apiClient.importBoard(file)
commit('addBoard', board)
} catch (err) {
return err
}
},
async cloneBoard({ commit }, { boardData, settings }) { async cloneBoard({ commit }, { boardData, settings }) {
const { withCards, withAssignments, withLabels, withDueDate, moveCardsToLeftStack, restoreArchivedCards } = settings const { withCards, withAssignments, withLabels, withDueDate, moveCardsToLeftStack, restoreArchivedCards } = settings

View File

@@ -29,6 +29,7 @@ use OCA\Deck\Controller\PageController;
use OCA\Deck\NoPermissionException; use OCA\Deck\NoPermissionException;
use OCA\Deck\NotFoundException; use OCA\Deck\NotFoundException;
use OCA\Deck\Service\BoardService; use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\Importer\BoardImportService;
use OCA\Deck\Service\PermissionService; use OCA\Deck\Service\PermissionService;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\JSONResponse;
@@ -86,7 +87,15 @@ class ExceptionMiddlewareTest extends \Test\TestCase {
public function testAfterExceptionFail() { public function testAfterExceptionFail() {
$this->request->expects($this->any())->method('getId')->willReturn('abc123'); $this->request->expects($this->any())->method('getId')->willReturn('abc123');
// BoardService $boardService, PermissionService $permissionService, $userId // BoardService $boardService, PermissionService $permissionService, $userId
$boardController = new BoardController('deck', $this->createMock(IRequest::class), $this->createMock(BoardService::class), $this->createMock(PermissionService::class), 'admin'); $boardController = new BoardController(
'deck',
$this->createMock(IRequest::class),
$this->createMock(BoardService::class),
$this->createMock(PermissionService::class),
$this->createMock(BoardImportService::class),
$this->createMock(\OCP\IL10N::class),
'admin'
);
$result = $this->exceptionMiddleware->afterException($boardController, 'bar', new \Exception('other exception message')); $result = $this->exceptionMiddleware->afterException($boardController, 'bar', new \Exception('other exception message'));
$this->assertEquals('Internal server error: Please contact the server administrator if this error reappears multiple times, please include the request ID "abc123" below in your report.', $result->getData()['message']); $this->assertEquals('Internal server error: Please contact the server administrator if this error reappears multiple times, please include the request ID "abc123" below in your report.', $result->getData()['message']);
$this->assertEquals(500, $result->getData()['status']); $this->assertEquals(500, $result->getData()['status']);

View File

@@ -36,6 +36,7 @@ class BoardControllerTest extends \Test\TestCase {
private $groupManager; private $groupManager;
private $boardService; private $boardService;
private $permissionService; private $permissionService;
private $boardImportService;
private $userId = 'user'; private $userId = 'user';
public function setUp(): void { public function setUp(): void {
@@ -63,6 +64,10 @@ class BoardControllerTest extends \Test\TestCase {
'\OCA\Deck\Service\PermissionService') '\OCA\Deck\Service\PermissionService')
->disableOriginalConstructor() ->disableOriginalConstructor()
->getMock(); ->getMock();
$this->boardImportService = $this->getMockBuilder(
'\OCA\Deck\Service\Importer\BoardImportService')
->disableOriginalConstructor()
->getMock();
$user = $this->createMock(IUser::class); $user = $this->createMock(IUser::class);
$this->groupManager->method('getUserGroupIds') $this->groupManager->method('getUserGroupIds')
@@ -76,6 +81,8 @@ class BoardControllerTest extends \Test\TestCase {
$this->request, $this->request,
$this->boardService, $this->boardService,
$this->permissionService, $this->permissionService,
$this->boardImportService,
$this->l10n,
$this->userId $this->userId
); );
} }