diff --git a/appinfo/routes.php b/appinfo/routes.php index 63cabc6fa..4adb828b4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -29,6 +29,7 @@ return [ ['name' => 'board#clone', 'url' => '/boards/{boardId}/clone', 'verb' => 'POST'], ['name' => 'board#transferOwner', 'url' => '/boards/{boardId}/transferOwner', 'verb' => 'PUT'], ['name' => 'board#export', 'url' => '/boards/{boardId}/export', 'verb' => 'GET'], + ['name' => 'board#import', 'url' => '/boards/import', 'verb' => 'POST'], // stacks ['name' => 'stack#index', 'url' => '/stacks/{boardId}', 'verb' => 'GET'], diff --git a/cypress/e2e/boardFeatures.js b/cypress/e2e/boardFeatures.js index 6bb7ee6cd..1343fa361 100644 --- a/cypress/e2e/boardFeatures.js +++ b/cypress/e2e/boardFeatures.js @@ -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') + }) +}) diff --git a/cypress/fixtures/import-board.json b/cypress/fixtures/import-board.json new file mode 100644 index 000000000..be8b18f94 --- /dev/null +++ b/cypress/fixtures/import-board.json @@ -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" + } + ] +} diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index e79ac276e..ebd856d4d 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -10,10 +10,12 @@ namespace OCA\Deck\Controller; use OCA\Deck\Db\Acl; use OCA\Deck\Db\Board; use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\Importer\BoardImportService; use OCA\Deck\Service\PermissionService; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; +use OCP\IL10N; use OCP\IRequest; class BoardController extends ApiController { @@ -22,6 +24,8 @@ class BoardController extends ApiController { IRequest $request, private BoardService $boardService, private PermissionService $permissionService, + private BoardImportService $boardImportService, + private IL10N $l10n, private $userId, ) { parent::__construct($appName, $request); @@ -163,4 +167,62 @@ class BoardController extends ApiController { public function 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); + } + } } diff --git a/src/components/navigation/AppNavigation.vue b/src/components/navigation/AppNavigation.vue index 013c26844..edab125fb 100644 --- a/src/components/navigation/AppNavigation.vue +++ b/src/components/navigation/AppNavigation.vue @@ -42,6 +42,7 @@ + @@ -116,6 +117,7 @@ import DeckIcon from './../icons/DeckIcon.vue' import ShareVariantIcon from 'vue-material-design-icons/Share.vue' import HelpModal from './../modals/HelpModal.vue' import { subscribe } from '@nextcloud/event-bus' +import AppNavigationImportBoard from './AppNavigationImportBoard.vue' const canCreateState = loadState('deck', 'canCreate') @@ -127,6 +129,7 @@ export default { NcButton, AppNavigationAddBoard, AppNavigationBoardCategory, + AppNavigationImportBoard, NcSelect, NcAppNavigationItem, ArchiveIcon, diff --git a/src/components/navigation/AppNavigationBoard.vue b/src/components/navigation/AppNavigationBoard.vue index 47282e1e2..7219c1f7d 100644 --- a/src/components/navigation/AppNavigationBoard.vue +++ b/src/components/navigation/AppNavigationBoard.vue @@ -15,6 +15,10 @@ + @@ -161,6 +165,8 @@ import { emit } from '@nextcloud/event-bus' import isTouchDevice from '../../mixins/isTouchDevice.js' import BoardCloneModal from './BoardCloneModal.vue' +import BoardExportModal from './BoardExportModal.vue' +import { showLoading } from '@nextcloud/dialogs' const canCreateState = loadState('deck', 'canCreate') @@ -179,6 +185,7 @@ export default { CloseIcon, CheckIcon, BoardCloneModal, + BoardExportModal, }, directives: { ClickOutside, @@ -207,6 +214,7 @@ export default { updateDueSetting: null, canCreate: canCreateState, cloneModalOpen: false, + exportModalOpen: false, } }, computed: { @@ -346,7 +354,16 @@ export default { this.updateDueSetting = null }, 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() { if (this.isTouchDevice) { diff --git a/src/components/navigation/AppNavigationImportBoard.vue b/src/components/navigation/AppNavigationImportBoard.vue new file mode 100644 index 000000000..bd8d9f001 --- /dev/null +++ b/src/components/navigation/AppNavigationImportBoard.vue @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/src/components/navigation/BoardExportModal.vue b/src/components/navigation/BoardExportModal.vue new file mode 100644 index 000000000..10a354417 --- /dev/null +++ b/src/components/navigation/BoardExportModal.vue @@ -0,0 +1,78 @@ + + + + + + {{ t('deck', 'Export as JSON') }} + + + {{ t('deck', 'Export as CSV') }} + + + + {{ t('deck', 'Note: Only the JSON format is supported for importing back into the Deck app.') }} + + + + + + {{ t('deck', 'Cancel') }} + + + {{ t('deck', 'Export') }} + + + + + + + + diff --git a/src/services/BoardApi.js b/src/services/BoardApi.js index 41c5a0ea1..7a6ae24ff 100644 --- a/src/services/BoardApi.js +++ b/src/services/BoardApi.js @@ -139,10 +139,29 @@ export class BoardApi { } } - exportBoard(board) { + exportBoard(board, format) { return axios.get(this.url(`/boards/${board.id}/export`)) .then( (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') } let row = '' 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 deleteLabel(id) { return axios.delete(this.url(`/labels/${id}`)) diff --git a/src/store/main.js b/src/store/main.js index 8ace5a4a0..a61a85f2b 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -398,6 +398,14 @@ export default new Vuex.Store({ 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 }) { const { withCards, withAssignments, withLabels, withDueDate, moveCardsToLeftStack, restoreArchivedCards } = settings diff --git a/tests/unit/Middleware/ExceptionMiddlewareTest.php b/tests/unit/Middleware/ExceptionMiddlewareTest.php index a076061a2..198d95e63 100644 --- a/tests/unit/Middleware/ExceptionMiddlewareTest.php +++ b/tests/unit/Middleware/ExceptionMiddlewareTest.php @@ -29,6 +29,7 @@ use OCA\Deck\Controller\PageController; use OCA\Deck\NoPermissionException; use OCA\Deck\NotFoundException; use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\Importer\BoardImportService; use OCA\Deck\Service\PermissionService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\JSONResponse; @@ -86,7 +87,15 @@ class ExceptionMiddlewareTest extends \Test\TestCase { public function testAfterExceptionFail() { $this->request->expects($this->any())->method('getId')->willReturn('abc123'); // 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')); $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']); diff --git a/tests/unit/controller/BoardControllerTest.php b/tests/unit/controller/BoardControllerTest.php index 9cc732b17..298e1c9c5 100644 --- a/tests/unit/controller/BoardControllerTest.php +++ b/tests/unit/controller/BoardControllerTest.php @@ -36,6 +36,7 @@ class BoardControllerTest extends \Test\TestCase { private $groupManager; private $boardService; private $permissionService; + private $boardImportService; private $userId = 'user'; public function setUp(): void { @@ -63,6 +64,10 @@ class BoardControllerTest extends \Test\TestCase { '\OCA\Deck\Service\PermissionService') ->disableOriginalConstructor() ->getMock(); + $this->boardImportService = $this->getMockBuilder( + '\OCA\Deck\Service\Importer\BoardImportService') + ->disableOriginalConstructor() + ->getMock(); $user = $this->createMock(IUser::class); $this->groupManager->method('getUserGroupIds') @@ -76,6 +81,8 @@ class BoardControllerTest extends \Test\TestCase { $this->request, $this->boardService, $this->permissionService, + $this->boardImportService, + $this->l10n, $this->userId ); }
+ {{ t('deck', 'Note: Only the JSON format is supported for importing back into the Deck app.') }} +