feat: add board import and export
Signed-off-by: Luka Trovic <luka@nextcloud.com>
This commit is contained in:
@@ -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'],
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
102
cypress/fixtures/import-board.json
Normal file
102
cypress/fixtures/import-board.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
55
src/components/navigation/AppNavigationImportBoard.vue
Normal file
55
src/components/navigation/AppNavigationImportBoard.vue
Normal 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>
|
||||||
78
src/components/navigation/BoardExportModal.vue
Normal file
78
src/components/navigation/BoardExportModal.vue
Normal 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>
|
||||||
@@ -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}`))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user