From 03cdc47540cd977a9e5e7f9a52e3d362471230eb Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Tue, 1 Apr 2025 18:54:12 +0200 Subject: [PATCH] feat: add board import and export Signed-off-by: Luka Trovic --- appinfo/routes.php | 1 + cypress/e2e/boardFeatures.js | 78 ++++++++++++++ cypress/fixtures/import-board.json | 102 ++++++++++++++++++ lib/Controller/BoardController.php | 62 +++++++++++ src/components/navigation/AppNavigation.vue | 3 + .../navigation/AppNavigationBoard.vue | 19 +++- .../navigation/AppNavigationImportBoard.vue | 55 ++++++++++ .../navigation/BoardExportModal.vue | 78 ++++++++++++++ src/services/BoardApi.js | 42 +++++++- src/store/main.js | 8 ++ .../Middleware/ExceptionMiddlewareTest.php | 11 +- tests/unit/controller/BoardControllerTest.php | 7 ++ 12 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 cypress/fixtures/import-board.json create mode 100644 src/components/navigation/AppNavigationImportBoard.vue create mode 100644 src/components/navigation/BoardExportModal.vue 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 @@ +