From 1043046eabbff338aa334f2fa9dce07a1fd6e7ca Mon Sep 17 00:00:00 2001 From: TimedIn Date: Thu, 4 Jun 2026 22:51:29 +0200 Subject: [PATCH 1/2] feat: downloading forms Signed-off-by: TimedIn --- lib/Controller/ApiController.php | 40 +++++- openapi.json | 45 ++++--- playwright/e2e/download-import-form.spec.ts | 133 ++++++++++++++++++++ src/Forms.vue | 54 ++++++++ src/components/AppNavigationForm.vue | 23 +++- src/components/ArchivedFormsModal.vue | 2 +- 6 files changed, 278 insertions(+), 19 deletions(-) create mode 100644 playwright/e2e/download-import-form.spec.ts diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index ebc411fd1..585a50b60 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -23,11 +23,13 @@ use OCA\Forms\Db\UploadedFile; use OCA\Forms\Db\UploadedFileMapper; use OCA\Forms\Exception\NoSuchFormException; +use OCA\Forms\Helper\FilePathHelper; use OCA\Forms\ResponseDefinitions; use OCA\Forms\Service\ConfigService; use OCA\Forms\Service\ConfirmationEmailService; use OCA\Forms\Service\FormsService; use OCA\Forms\Service\SubmissionService; +use OCP\App\IAppManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; use OCP\AppFramework\Http; @@ -76,6 +78,7 @@ public function __construct( IRequest $request, IUserSession $userSession, private readonly AnswerMapper $answerMapper, + private readonly FilePathHelper $filePathHelper, private readonly FormMapper $formMapper, private readonly OptionMapper $optionMapper, private readonly QuestionMapper $questionMapper, @@ -86,6 +89,7 @@ public function __construct( private readonly SubmissionService $submissionService, private readonly IL10N $l10n, private readonly LoggerInterface $logger, + private readonly IAppManager $appManager, private readonly IUserManager $userManager, private readonly IRootFolder $rootFolder, private readonly UploadedFileMapper $uploadedFileMapper, @@ -251,7 +255,8 @@ public function newForm(?int $fromId = null): DataResponse { * Read all information to edit a Form (form, questions, options, except submissions/answers) * * @param int $formId Id of the form - * @return DataResponse + * @param ?bool $download if the form should be downloaded + * @return DataResponse|DataDownloadResponse * @throws OCSBadRequestException Could not find form * @throws OCSForbiddenException User has no permissions to get this form * @@ -261,9 +266,40 @@ public function newForm(?int $fromId = null): DataResponse { #[NoAdminRequired()] #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}')] - public function getForm(int $formId): DataResponse { + public function getForm(int $formId, ?bool $download): DataResponse|DataDownloadResponse { $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_SUBMIT); + if ($download) { + $formData = $this->formsService->getPublicForm($form); + unset($formData['hash']); + unset($formData['created']); + unset($formData['lastUpdated']); + unset($formData['lockedBy']); + unset($formData['lockedUntil']); + unset($formData['permissions']); + unset($formData['canSubmit']); + unset($formData['isMaxSubmissionsReached']); + unset($formData['submissionCount']); + unset($formData['filePath']); + unset($formData['state']); + unset($formData['id']); + + foreach ($formData['questions'] as &$question) { + unset($question['formId']); + unset($question['accept']); + foreach ($question['options'] as &$option) { + unset($option['questionId']); + unset($option['id']); + } + } + + $downloadFile = ['form' => $formData,'appVersion' => $this->appManager->getAppVersion('forms')]; + $fileName = $this->filePathHelper->normalizeFileName($form->getTitle() . '.json'); + + return new DataDownloadResponse( + json_encode($downloadFile), $fileName, 'application/json'); + } + return new DataResponse($this->formsService->getForm($form)); } diff --git a/openapi.json b/openapi.json index f9eb1da32..d0372e0f6 100644 --- a/openapi.json +++ b/openapi.json @@ -1005,6 +1005,15 @@ "format": "int64" } }, + { + "name": "download", + "in": "query", + "description": "if the form should be downloaded", + "schema": { + "type": "boolean", + "nullable": true + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -1022,27 +1031,35 @@ "content": { "application/json": { "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { + "anyOf": [ + { "type": "object", "required": [ - "meta", - "data" + "ocs" ], "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "$ref": "#/components/schemas/Form" + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Form" + } + } } } + }, + { + "type": "string", + "format": "binary" } - } + ] } } } diff --git a/playwright/e2e/download-import-form.spec.ts b/playwright/e2e/download-import-form.spec.ts new file mode 100644 index 000000000..77a8c0f56 --- /dev/null +++ b/playwright/e2e/download-import-form.spec.ts @@ -0,0 +1,133 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as formTest } from '../support/fixtures/form.ts' +import { test as appNavigationTest } from '../support/fixtures/navigation.ts' +import { test as randomUserTest } from '../support/fixtures/random-user.ts' +import { QuestionType } from '../support/sections/QuestionType.ts' + +const test = mergeTests(randomUserTest, appNavigationTest, formTest) + +test.describe('Download form', () => { + test.beforeEach(async ({ page }) => { + await page.goto('apps/forms', { waitUntil: 'networkidle' }) + await page.waitForURL(/apps\/forms\/$/) + }) + + test('Download a form as JSON file', async ({ page, appNavigation, form }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Download test') + await page.getByRole('button', { name: 'Add a question' }).click() + await page.getByRole('menuitem', { name: 'Checkboxes' }).click() + await page + .getByRole('textbox', { name: 'Title of question number 1' }) + .fill('A') + await page + .getByRole('listitem', { name: 'Question number 1' }) + .getByPlaceholder('Add a new answer option') + .fill('B') + await page + .getByRole('listitem', { name: 'Question number 1' }) + .getByPlaceholder('Add a new answer option') + .press('Enter') + await page.waitForTimeout(100) + + await page + .getByRole('listitem', { name: 'Question number 1' }) + .getByPlaceholder('Add a new answer option') + .fill('C') + await page + .getByRole('listitem', { name: 'Question number 1' }) + .getByPlaceholder('Add a new answer option') + .press('Enter') + await page.waitForTimeout(100) + + await page + .getByRole('listitem', { name: 'Question number 1' }) + .getByPlaceholder('Add a new answer option') + .fill('D') + await page + .getByRole('listitem', { name: 'Question number 1' }) + .getByPlaceholder('Add a new answer option') + .press('Enter') + await page.waitForTimeout(100) + + const downloadPromise = page.waitForEvent('download') + + // Hover over the form to make the actions button visible + await appNavigation.getOwnForm('Download test').hover() + await page.getByRole('button', { name: 'Form actions' }).click() + + // Click Download form in the popover menu + await page.getByRole('menuitem', { name: 'Download form' }).click() + + const download = await downloadPromise + expect(download.suggestedFilename()).toMatch(/^Download test.*\.json$/) + + const stream = await download.createReadStream() + const json = await new Promise((resolve, reject) => { + let raw = '' + stream.on('data', (chunk) => (raw += chunk)) + stream.on('end', () => resolve(JSON.parse(raw))) + stream.on('error', reject) + }) + expect(json.form).toBeDefined() + expect(json.form.questions).toHaveLength(1) + expect(json.appVersion).toBeDefined() + // Deepen: verify the JSON content matches what we created + expect(json.form.title).toBe('Download test') + expect(json.form.questions[0].type).toBe('multiple') + expect(json.form.questions[0].text).toBe('A') + expect(json.form.questions[0].options).toHaveLength(3) + expect(json.form.questions[0].options[0].text).toBe('B') + expect(json.form.questions[0].options[1].text).toBe('C') + expect(json.form.questions[0].options[2].text).toBe('D') + // Verify stripped fields are absent + expect(json.form.id).toBeUndefined() + expect(json.form.hash).toBeUndefined() + expect(json.form.ownerId).toBeUndefined() + }) + + test('Download a form with multiple question types', async ({ + page, + appNavigation, + form, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Multi type form') + await form.addQuestion(QuestionType.ShortAnswer) + const q1 = await form.getQuestions() + await q1[0].fillTitle('Name') + await form.addQuestion(QuestionType.Dropdown) + const q2 = await form.getQuestions() + await q2[1].fillTitle('Country') + await q2[1].addAnswer('DE') + await q2[1].addAnswer('FR') + await q2[1].addAnswer('IT') + + await page.waitForLoadState('networkidle') + const downloadPromise = page.waitForEvent('download') + + await appNavigation.getOwnForm('Multi type form').hover() + await page.getByRole('button', { name: 'Form actions' }).click() + await page.getByRole('menuitem', { name: 'Download form' }).click() + + const download = await downloadPromise + const stream = await download.createReadStream() + const json = await new Promise((resolve, reject) => { + let raw = '' + stream.on('data', (chunk) => (raw += chunk)) + stream.on('end', () => resolve(JSON.parse(raw))) + stream.on('error', reject) + }) + expect(json.form.questions).toHaveLength(2) + expect(json.form.questions[0].type).toBe('short') + expect(json.form.questions[0].text).toBe('Name') + expect(json.form.questions[1].type).toBe('dropdown') + expect(json.form.questions[1].text).toBe('Country') + expect(json.form.questions[1].options).toHaveLength(3) + }) +}) diff --git a/src/Forms.vue b/src/Forms.vue index 7755dc57f..66ba996d8 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -170,6 +170,7 @@ import AppNavigationForm from './components/AppNavigationForm.vue' import ArchivedFormsModal from './components/ArchivedFormsModal.vue' import Sidebar from './views/Sidebar.vue' import FormsIcon from '../img/forms-dark.svg?raw' +import { version } from '../package.json' import PermissionTypes from './mixins/PermissionTypes.ts' import { FormState } from './models/Constants.ts' import logger from './utils/Logger.ts' @@ -487,6 +488,58 @@ export default { } } + const onDownloadForm = async (id) => { + try { + const response = await axios.get( + generateOcsUrl('apps/forms/api/v3/forms/{id}', { + id, + }), + ) + const form = OcsResponse2Data(response) + + // download only required values + const download = { + appVersion: version, + form: { + ...form, + // Remove unused values + ...[ + 'hash', + 'ownerId', + 'created', + 'access', + 'lastUpdated', + 'lockedBy', + 'lockedUntil', + 'shares', + 'permissions', + 'canSubmit', + 'isMaxSubmissionsReached', + 'submissionCount', + ].reduce((prev, curr) => { + prev[curr] = undefined + return prev + }, {}), + + id: undefined, + questions: form.questions, + }, + } + // create blob and download + const blob = new Blob([JSON.stringify(download)]) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + const formTitle = form.title ? form.title : t('forms', 'New form') + a.download = `${formTitle}.json` + a.click() + URL.revokeObjectURL(url) + } catch (error) { + logger.error(`Unable to download form ${id}`, { error }) + showError(t('forms', 'Unable to download form')) + } + } + const onDeleteForm = async (id) => { const formIndex = forms.value.findIndex((form) => form.id === id) const deletedHash = forms.value[formIndex].hash @@ -572,6 +625,7 @@ export default { fetchPartialForm, onNewForm, onCloneForm, + onDownloadForm, onDeleteForm, onLastUpdatedByEventBus, IconPlus, diff --git a/src/components/AppNavigationForm.vue b/src/components/AppNavigationForm.vue index 232bea551..20b4c5f15 100644 --- a/src/components/AppNavigationForm.vue +++ b/src/components/AppNavigationForm.vue @@ -64,6 +64,12 @@ {{ t('forms', 'Copy form') }} + + + {{ t('forms', 'Download form') }} + Date: Fri, 5 Jun 2026 23:07:51 +0200 Subject: [PATCH 2/2] feat: add import function Signed-off-by: TimedIn --- lib/Controller/ApiController.php | 125 +++++++++++-- openapi.json | 43 +++++ package-lock.json | 108 +---------- package.json | 1 + playwright/e2e/download-import-form.spec.ts | 176 ++++++++++++++++++ src/Forms.vue | 140 +++++++++++++- tests/Unit/Controller/ApiControllerTest.php | 196 +++++++++++++++++++- 7 files changed, 662 insertions(+), 127 deletions(-) diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 585a50b60..bd35c39b4 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -152,8 +152,19 @@ public function getForms(string $type = 'owned'): DataResponse { * Return a copy of the form if the parameter $fromId is set * * @param ?int $fromId (optional) Id of the form that should be cloned + * @param ?bool $import (optional) If it should import the form from post body + * @param ?array $formData (optional) The formdata to import * @return DataResponse * @throws OCSForbiddenException The user is not allowed to create forms + * @throws OCSBadRequestException Cannot use both fromId and import parameters + * @throws OCSBadRequestException Invalid form data: missing questions + * @throws OCSBadRequestException Invalid form data: unknown properties + * @throws OCSBadRequestException Invalid question data: missing id + * @throws OCSBadRequestException Invalid question data: unknown properties + * @throws OCSBadRequestException Invalid question data: invalid type + * @throws OCSBadRequestException Invalid question data: datetime type no longer supported + * @throws OCSBadRequestException Invalid question data: invalid extraSettings + * @throws OCSBadRequestException Invalid option data: unknown properties * * 201: the created form */ @@ -161,14 +172,19 @@ public function getForms(string $type = 'owned'): DataResponse { #[NoAdminRequired()] #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'POST', url: '/api/v3/forms')] - public function newForm(?int $fromId = null): DataResponse { + public function newForm(?int $fromId = null, ?bool $import = false, ?array $formData = []): DataResponse { // Check if user is allowed if (!$this->configService->canCreateForms()) { $this->logger->debug('This user is not allowed to create Forms.'); throw new OCSForbiddenException('This user is not allowed to create Forms.'); } - if ($fromId === null) { + // Validate mutually exclusive parameters + if ($fromId !== null && $import === true) { + throw new OCSBadRequestException('Cannot use both fromId and import parameters'); + } + + if ($fromId === null && $import === false) { // Create Form $form = new Form(); $form->setOwnerId($this->currentUser->getUID()); @@ -187,10 +203,37 @@ public function newForm(?int $fromId = null): DataResponse { $this->formMapper->insert($form); } else { - $oldForm = $this->formsService->getFormIfAllowed($fromId, Constants::PERMISSION_EDIT); + // Fill variables from json or database + if ($import) { + if (!isset($formData['questions']) || !\is_array($formData['questions'])) { + throw new OCSBadRequestException('Invalid form data: missing questions'); + } + $questions = $formData['questions']; + $oldConfirmationEmailQuestionId = $formData['confirmationEmailQuestionId'] ?? null; + unset($formData['questions']); + + // Validate form data whitelist + $allowedFormProperties = [ + 'title', 'description', 'access', 'expires', 'isAnonymous', + 'submitMultiple', 'allowEditSubmissions', 'showExpiration', + 'submissionMessage', 'maxSubmissions', 'confirmationEmailEnabled', + 'confirmationEmailSubject', 'confirmationEmailBody', 'confirmationEmailQuestionId', + 'allowComments', + ]; + $invalidKeys = array_diff(array_keys($formData), $allowedFormProperties); + if (!empty($invalidKeys)) { + throw new OCSBadRequestException('Invalid form data: unknown properties: ' . implode(', ', $invalidKeys)); + } + } else { + $oldForm = $this->formsService->getFormIfAllowed($fromId, Constants::PERMISSION_EDIT); - // Read old form, (un)set new form specific data, extend title - $formData = $oldForm->read(); + // Read old form, (un)set new form specific data, extend title + $formData = $oldForm->read(); + // Get Questions, set new formId, reinsert + $questions = $this->questionMapper->findByForm($oldForm->getId()); + $oldConfirmationEmailQuestionId = $oldForm->getConfirmationEmailQuestionId(); + } + // Remove unused data unset($formData['id']); unset($formData['created']); unset($formData['lastUpdated']); @@ -203,7 +246,9 @@ public function newForm(?int $fromId = null): DataResponse { $formData['ownerId'] = $this->currentUser->getUID(); $formData['hash'] = $this->formsService->generateFormHash(); // TRANSLATORS Appendix to the form Title of a duplicated/copied form. - $formData['title'] .= ' - ' . $this->l10n->t('Copy'); + if (!$import) { + $formData['title'] .= ' - ' . $this->l10n->t('Copy'); + } $formData['access'] = [ 'permitAllUsers' => false, 'showToAllUsers' => false, @@ -217,26 +262,70 @@ public function newForm(?int $fromId = null): DataResponse { $form = Form::fromParams($formData); $this->formMapper->insert($form); - // Get Questions, set new formId, reinsert - $questions = $this->questionMapper->findByForm($oldForm->getId()); - $oldConfirmationEmailQuestionId = $oldForm->getConfirmationEmailQuestionId(); - foreach ($questions as $oldQuestion) { - $questionData = $oldQuestion->read(); + if ($import) { + if (!isset($oldQuestion['id'])) { + throw new OCSBadRequestException('Invalid question data: missing id'); + } + + // Validate question property whitelist + $allowedQuestionProperties = ['id', 'order', 'type', 'isRequired', 'text', 'name', 'description', 'extraSettings', 'options']; + $invalidQuestionKeys = array_diff(array_keys($oldQuestion), $allowedQuestionProperties); + if (!empty($invalidQuestionKeys)) { + throw new OCSBadRequestException('Invalid question data: unknown properties: ' . implode(', ', $invalidQuestionKeys)); + } + + // Validate question type + $type = $oldQuestion['type'] ?? null; + if ($type === null || array_search($type, Constants::ANSWER_TYPES) === false) { + throw new OCSBadRequestException('Invalid question data: invalid type'); + } + + // Block datetime questions + if ($type === 'datetime') { + throw new OCSBadRequestException('Invalid question data: datetime type no longer supported'); + } + + // Validate extraSettings + if (!empty($oldQuestion['extraSettings'] ?? [])) { + if (!$this->formsService->areExtraSettingsValid($oldQuestion['extraSettings'], $type)) { + throw new OCSBadRequestException('Invalid question data: invalid extraSettings'); + } + } + + $questionData = $oldQuestion; + $oldQuestionId = $oldQuestion['id']; + $options = $oldQuestion['options'] ?? []; + } else { + $questionData = $oldQuestion->read(); + $oldQuestionId = $oldQuestion->getId(); + // Get Options, set new QuestionId, reinsert + $options = $this->optionMapper->findByQuestion($oldQuestionId); + } unset($questionData['id']); + unset($questionData['options']); + unset($questionData['accept']); + $questionData['formId'] = $form->getId(); $newQuestion = Question::fromParams($questionData); $this->questionMapper->insert($newQuestion); - if (isset($oldConfirmationEmailQuestionId) && $oldConfirmationEmailQuestionId === $oldQuestion->getId()) { + if (isset($oldConfirmationEmailQuestionId) && $oldConfirmationEmailQuestionId === $oldQuestionId) { $form->setConfirmationEmailQuestionId($newQuestion->getId()); } - // Get Options, set new QuestionId, reinsert - $options = $this->optionMapper->findByQuestion($oldQuestion->getId()); foreach ($options as $oldOption) { - $optionData = $oldOption->read(); + $optionData = $import ? $oldOption : $oldOption->read(); + + if ($import) { + // Validate option property whitelist + $allowedOptionProperties = ['text', 'order', 'optionType']; + $invalidOptionKeys = array_diff(array_keys($optionData), $allowedOptionProperties); + if (!empty($invalidOptionKeys)) { + throw new OCSBadRequestException('Invalid option data: unknown properties: ' . implode(', ', $invalidOptionKeys)); + } + } unset($optionData['id']); $optionData['questionId'] = $newQuestion->getId(); @@ -730,8 +819,10 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair throw new OCSBadRequestException('Invalid extraSettings, will not update.'); } - if ($form->getConfirmationEmailQuestionId() === $question->getId() - && !$question->isEmailType($keyValuePairs['type'] ?? null, $keyValuePairs['extraSettings'] ?? null)) { + if ( + $form->getConfirmationEmailQuestionId() === $question->getId() + && !$question->isEmailType($keyValuePairs['type'] ?? null, $keyValuePairs['extraSettings'] ?? null) + ) { $form->setConfirmationEmailQuestionId(null); } diff --git a/openapi.json b/openapi.json index d0372e0f6..13657e310 100644 --- a/openapi.json +++ b/openapi.json @@ -873,6 +873,21 @@ "nullable": true, "default": null, "description": "(optional) Id of the form that should be cloned" + }, + "import": { + "type": "boolean", + "nullable": true, + "default": false, + "description": "(optional) If it should import the form from post body" + }, + "formData": { + "type": "object", + "nullable": true, + "default": {}, + "description": "(optional) The formdata to import", + "additionalProperties": { + "type": "object" + } } } } @@ -950,6 +965,34 @@ } } }, + "400": { + "description": "Invalid option data: unknown properties", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { diff --git a/package-lock.json b/package-lock.json index f785c8ee2..e6eda76a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "markdown-it": "^14.3.0", "p-queue": "^9.3.1", "qrcode": "^1.5.4", + "semver": "^7.8.5", "vue": "^3.5.22", "vue-draggable-plus": "^0.6.1", "vue-router": "^5.2.0" @@ -502,7 +503,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -519,7 +519,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -536,7 +535,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -553,7 +551,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -570,7 +567,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -587,7 +583,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -604,7 +599,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -621,7 +615,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -638,7 +631,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -655,7 +647,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -672,7 +663,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -689,7 +679,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -706,7 +695,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -723,7 +711,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -740,7 +727,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -757,7 +743,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -774,7 +759,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -791,7 +775,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -808,7 +791,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -825,7 +807,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -842,7 +823,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -859,7 +839,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -876,7 +855,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -893,7 +871,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -910,7 +887,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -927,7 +903,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2020,7 +1995,6 @@ "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2061,7 +2035,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2083,7 +2056,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2105,7 +2077,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2127,7 +2098,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2149,7 +2119,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2171,7 +2140,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2193,7 +2161,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2215,7 +2182,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2237,7 +2203,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2259,7 +2224,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2281,7 +2245,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2303,7 +2266,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2325,7 +2287,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2517,7 +2478,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2531,7 +2491,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2545,7 +2504,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2559,7 +2517,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2573,7 +2530,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2587,7 +2543,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2601,7 +2556,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2615,7 +2569,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2629,7 +2582,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2643,7 +2595,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2657,7 +2608,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2671,7 +2621,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2685,7 +2634,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2699,7 +2647,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2713,7 +2660,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2727,7 +2673,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2741,7 +2686,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2755,7 +2699,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2769,7 +2712,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2783,7 +2725,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2797,7 +2738,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2811,7 +2751,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2825,7 +2764,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2839,7 +2777,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2853,7 +2790,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3188,7 +3124,7 @@ "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -5402,7 +5338,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "optional": true, "peer": true, @@ -6645,7 +6580,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7209,7 +7143,7 @@ "version": "5.1.5", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -7395,7 +7329,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -7435,7 +7369,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -8932,7 +8866,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT", "optional": true, "peer": true @@ -10650,7 +10583,7 @@ "version": "1.100.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -11985,7 +11918,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -12463,7 +12396,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12480,7 +12412,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12497,7 +12428,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12514,7 +12444,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12531,7 +12460,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12548,7 +12476,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12565,7 +12492,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12582,7 +12508,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12599,7 +12524,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12616,7 +12540,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12633,7 +12556,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12650,7 +12572,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12667,7 +12588,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12684,7 +12604,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12701,7 +12620,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12718,7 +12636,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12735,7 +12652,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12752,7 +12668,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12769,7 +12684,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12786,7 +12700,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12803,7 +12716,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12820,7 +12732,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12837,7 +12748,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12854,7 +12764,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12871,7 +12780,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12888,7 +12796,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12944,7 +12851,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index 599b85c95..783f22d93 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "markdown-it": "^14.3.0", "p-queue": "^9.3.1", "qrcode": "^1.5.4", + "semver": "^7.8.5", "vue": "^3.5.22", "vue-draggable-plus": "^0.6.1", "vue-router": "^5.2.0" diff --git a/playwright/e2e/download-import-form.spec.ts b/playwright/e2e/download-import-form.spec.ts index 77a8c0f56..1f1da0313 100644 --- a/playwright/e2e/download-import-form.spec.ts +++ b/playwright/e2e/download-import-form.spec.ts @@ -131,3 +131,179 @@ test.describe('Download form', () => { expect(json.form.questions[1].options).toHaveLength(3) }) }) + +test.describe('Import form', () => { + test.beforeEach(async ({ page }) => { + await page.goto('apps/forms', { waitUntil: 'networkidle' }) + await page.waitForURL(/apps\/forms\/$/) + }) + + test('Import a form from JSON file', async ({ page, appNavigation }) => { + // Prepare JSON data matching the export format + const formData = { + appVersion: '5.3.0-rc.0', + form: { + title: 'Imported test form', + description: 'Description', + questions: [ + { + id: 52, + order: 1, + type: 'multiple', + isRequired: false, + text: 'Checkbox', + name: '', + description: '', + extraSettings: [], + options: [ + { + order: 1, + text: 'A', + optionType: 'choice', + }, + { + order: 2, + text: 'B', + optionType: 'choice', + }, + ], + }, + { + id: 53, + order: 2, + type: 'short', + isRequired: false, + text: 'Text', + name: '', + description: '', + extraSettings: [], + options: [], + }, + ], + }, + } + + const jsonContent = JSON.stringify(formData) + + // Set up file chooser handler before clicking import + const fileChooserPromise = page.waitForEvent('filechooser') + + // Click the Import form button in the navigation + await page.getByRole('button', { name: 'Import a form' }).click() + + const fileChooser = await fileChooserPromise + await fileChooser.setFiles({ + name: 'imported-form.json', + mimeType: 'application/json', + buffer: Buffer.from(jsonContent), + }) + + // Wait for the imported form to appear in the navigation + await expect(appNavigation.getOwnForm('Imported test form')).toBeVisible({ + timeout: 10000, + }) + + await expect( + page.getByRole('textbox', { name: 'Title of question number 1' }), + ).toHaveValue('Checkbox') + await expect( + page.getByRole('textbox', { name: 'Description', exact: true }), + ).toHaveValue('Description') + await expect( + page.getByRole('textbox', { name: 'The text of option 1' }), + ).toHaveValue('A') + await expect( + page.getByRole('textbox', { name: 'The text of option 2' }), + ).toHaveValue('B') + await expect( + page.getByRole('textbox', { name: 'Title of question number 2' }), + ).toHaveValue('Text') + }) + + test('Import a form with a long text question', async ({ + page, + appNavigation, + }) => { + const formData = { + appVersion: '5.3.0-rc.0', + form: { + title: 'Long answer form', + description: 'Testing long text import', + questions: [ + { + id: 10, + order: 1, + type: 'long', + text: 'Your biography', + isRequired: true, + options: [], + }, + ], + }, + } + + const jsonContent = JSON.stringify(formData) + + const fileChooserPromise = page.waitForEvent('filechooser') + + // Click the Import form button in the navigation + await page.getByRole('button', { name: 'Import a form' }).click() + + const fileChooser = await fileChooserPromise + await fileChooser.setFiles({ + name: 'long-text.json', + mimeType: 'application/json', + buffer: Buffer.from(jsonContent), + }) + + await expect(appNavigation.getOwnForm('Long answer form')).toBeVisible() + await expect( + page.getByRole('textbox', { name: 'Title of question number 1' }), + ).toHaveValue('Your biography') + await expect( + page.getByRole('textbox', { name: 'Description', exact: true }), + ).toHaveValue('Testing long text import') + }) + + test('Import a form with confirmation email question remapping', async ({ + page, + appNavigation, + }) => { + const formData = { + appVersion: '5.3.0-rc.0', + form: { + title: 'Email confirmation', + description: '', + confirmationEmailQuestionId: 99, + questions: [ + { + id: 99, + order: 1, + type: 'short', + text: 'Your email', + options: [], + }, + ], + }, + } + + const jsonContent = JSON.stringify(formData) + + const fileChooserPromise = page.waitForEvent('filechooser') + + // Click the Import form button in the navigation + await page.getByRole('button', { name: 'Import a form' }).click() + + const fileChooser = await fileChooserPromise + await fileChooser.setFiles({ + name: 'email-confirm.json', + mimeType: 'application/json', + buffer: Buffer.from(jsonContent), + }) + + await expect(appNavigation.getOwnForm('Email confirmation')).toBeVisible() + await expect( + page.getByRole('textbox', { name: 'Title of question number 1' }), + ).toHaveValue('Your email') + }) +}) diff --git a/src/Forms.vue b/src/Forms.vue index 66ba996d8..f3b25ba07 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -23,7 +23,16 @@ isHeading class="forms-navigation__list-heading" headingId="forms-navigation-your-forms" - :name="t('forms', 'Your forms')" /> + :name="t('forms', 'Your forms')"> + +
    @@ -109,9 +123,14 @@ @@ -137,6 +156,31 @@ @update:active="sidebarActive = $event" /> + + + + + + import IconPlus from '@material-symbols/svg-400/outlined/add.svg?raw' import IconArchive from '@material-symbols/svg-400/outlined/archive.svg?raw' +import IconUpload from '@material-symbols/svg-400/outlined/upload.svg?raw' import axios from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' @@ -155,14 +200,17 @@ import { loadState } from '@nextcloud/initial-state' import moment from '@nextcloud/moment' import { generateOcsUrl } from '@nextcloud/router' import { useIsMobile } from '@nextcloud/vue' +import semverCompare from 'semver/functions/compare' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcAppContent from '@nextcloud/vue/components/NcAppContent' import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation' import NcAppNavigationCaption from '@nextcloud/vue/components/NcAppNavigationCaption' import NcAppNavigationNew from '@nextcloud/vue/components/NcAppNavigationNew' import NcButton from '@nextcloud/vue/components/NcButton' import NcContent from '@nextcloud/vue/components/NcContent' +import NcDialog from '@nextcloud/vue/components/NcDialog' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' @@ -193,6 +241,8 @@ export default { NcButton, NcContent, NcEmptyContent, + NcActionButton, + NcDialog, NcLoadingIcon, Sidebar, }, @@ -208,6 +258,8 @@ export default { const forms = ref([]) const allSharedForms = ref([]) const showArchivedForms = ref(false) + const showVersionMismatch = ref(false) + let formForImport = undefined const canCreateForms = ref(loadState(appName, 'appConfig').canCreateForms) const allowComments = ref(loadState(appName, 'appConfig').allowComments) const deletedFormHash = ref(null) @@ -488,6 +540,69 @@ export default { } } + const onImportForm = async () => { + showVersionMismatch.value = false + try { + const response = await axios.post( + generateOcsUrl('apps/forms/api/v3/forms?import=1'), + { formData: formForImport }, + ) + const newForm = OcsResponse2Data(response) + forms.value.unshift(newForm) + router.push({ + name: 'edit', + params: { hash: newForm.hash }, + }) + mobileCloseNavigation() + } catch (error) { + logger.error(`Unable to import form`, { error }) + showError(t('forms', 'Unable to import form')) + } + } + + const onUploadForm = () => { + // Open file pickers + const fileInput = document.createElement('input') + fileInput.type = 'file' + fileInput.accept = 'application/json' + fileInput.click() + + fileInput.addEventListener('change', () => { + const file = fileInput.files[0] + if (file.type !== 'application/json' || file.size > 1000 * 1000) { + showError(t('forms', 'Invalid file type or file too large')) + return + } + + const reader = new FileReader() + reader.addEventListener('load', async () => { + let formObject + try { + formObject = JSON.parse(reader.result) + } catch (error) { + logger.error('Invalid JSON in uploaded file', { error }) + showError(t('forms', 'Invalid JSON file')) + return + } + if (!formObject.appVersion || !formObject.form) { + showError(t('forms', 'Invalid form file format')) + return + } + formForImport = formObject.form + if (semverCompare(version, formObject.appVersion) === -1) { + showVersionMismatch.value = true + } else { + await onImportForm() + } + }) + reader.readAsText(file) + }) + } + + const closeModal = () => { + showVersionMismatch.value = false + formForImport = undefined + } const onDownloadForm = async (id) => { try { const response = await axios.get( @@ -607,6 +722,7 @@ export default { forms, allSharedForms, showArchivedForms, + showVersionMismatch, canCreateForms, allowComments, isMobile, @@ -626,10 +742,14 @@ export default { onNewForm, onCloneForm, onDownloadForm, + onUploadForm, onDeleteForm, + onImportForm, + closeModal, onLastUpdatedByEventBus, IconPlus, IconArchive, + IconUpload, FormsIcon, } }, @@ -637,6 +757,12 @@ export default {