diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 38de3d93d..ccc8ead9b 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -148,6 +148,8 @@ 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 $form (optional) The formdata to import * @return DataResponse * @throws OCSForbiddenException The user is not allowed to create forms * @@ -157,14 +159,14 @@ 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 $form = []): 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) { + if ($fromId === null && $import === false) { // Create Form $form = new Form(); $form->setOwnerId($this->currentUser->getUID()); @@ -183,10 +185,22 @@ public function newForm(?int $fromId = null): DataResponse { $this->formMapper->insert($form); } else { - $oldForm = $this->formsService->getFormIfAllowed($fromId, Constants::PERMISSION_EDIT); - - // Read old form, (un)set new form specific data, extend title - $formData = $oldForm->read(); + $formData = []; + $questions = []; + if ($fromId !== null) { + $oldForm = $this->formsService->getFormIfAllowed($fromId, Constants::PERMISSION_EDIT); + + // 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(); + } else { + $questions = $form['questions']; + $oldConfirmationEmailQuestionId = $form['confirmationEmailQuestionId']; + unset($form['questions']); + $formData = $form; + } unset($formData['id']); unset($formData['created']); unset($formData['lastUpdated']); @@ -199,7 +213,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 ($fromId !== null) { + $formData['title'] .= ' - ' . $this->l10n->t('Copy'); + } $formData['access'] = [ 'permitAllUsers' => false, 'showToAllUsers' => false, @@ -213,26 +229,36 @@ 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 ($fromId !== null) { + $questionData = $oldQuestion->read(); + $oldQuestionId = $oldQuestion->getId(); + // Get Options, set new QuestionId, reinsert + $options = $this->optionMapper->findByQuestion($oldQuestionId); + } else { + $questionData = $oldQuestion; + $oldQuestionId = $oldQuestion['id']; + $options = $oldQuestion["options"]; + } 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(); + if ($fromId !== null) { + $optionData = $oldOption->read(); + } else { + $optionData = $oldOption; + } unset($optionData['id']); $optionData['questionId'] = $newQuestion->getId(); diff --git a/openapi.json b/openapi.json index 3ddf90c46..15d4f23df 100644 --- a/openapi.json +++ b/openapi.json @@ -869,6 +869,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" + }, + "form": { + "type": "object", + "nullable": true, + "default": {}, + "description": "(optional) The formdata to import", + "additionalProperties": { + "type": "object" + } } } } diff --git a/package-lock.json b/package-lock.json index de4f4792c..3c065832e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "markdown-it": "^14.2.0", "p-queue": "^9.3.0", "qrcode": "^1.5.4", + "semver": "^7.8.2", "vue": "^3.5.22", "vue-draggable-plus": "^0.6.1", "vue-router": "^4.6.4" @@ -10607,9 +10608,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index 9b294924b..5e090103c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "markdown-it": "^14.2.0", "p-queue": "^9.3.0", "qrcode": "^1.5.4", + "semver": "^7.8.2", "vue": "^3.5.22", "vue-draggable-plus": "^0.6.1", "vue-router": "^4.6.4" diff --git a/src/Forms.vue b/src/Forms.vue index 457d44b1a..84d6b7ecb 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')"> + + @@ -51,6 +61,7 @@ readOnly @openSharing="openSharing" @clone="onCloneForm" + @download="onDownloadForm" @mobileCloseNavigation="mobileCloseNavigation" /> @@ -137,6 +148,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' import { loadState } from '@nextcloud/initial-state' import moment from '@nextcloud/moment' import { generateOcsUrl } from '@nextcloud/router' -import { useIsMobile } from '@nextcloud/vue' +import { NcActionButton, NcDialog, 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 NcAppContent from '@nextcloud/vue/components/NcAppContent' @@ -170,6 +208,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.js' import { FormState } from './models/Constants.ts' import logger from './utils/Logger.js' @@ -192,6 +231,8 @@ export default { NcButton, NcContent, NcEmptyContent, + NcActionButton, + NcDialog, NcLoadingIcon, Sidebar, }, @@ -207,6 +248,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) @@ -440,6 +483,108 @@ export default { } } + const onImportForm = async () => { + showVersionMismatch.value = false + try { + const response = await axios.post( + generateOcsUrl('apps/forms/api/v3/forms?import=1'), + { form: 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) + return + const reader = new FileReader() + reader.addEventListener('load', async () => { + const formObject = JSON.parse(reader.result) + if (!formObject.appVersion || !formObject.form) 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( + 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 @@ -495,6 +640,7 @@ export default { forms, allSharedForms, showArchivedForms, + showVersionMismatch, canCreateForms, allowComments, isMobile, @@ -513,10 +659,15 @@ export default { fetchPartialForm, onNewForm, onCloneForm, + onDownloadForm, + onUploadForm, onDeleteForm, + onImportForm, + closeModal, onLastUpdatedByEventBus, IconPlus, IconArchive, + IconUpload, FormsIcon, } }, diff --git a/src/components/AppNavigationForm.vue b/src/components/AppNavigationForm.vue index 909830779..f174ac608 100644 --- a/src/components/AppNavigationForm.vue +++ b/src/components/AppNavigationForm.vue @@ -64,6 +64,12 @@ {{ t('forms', 'Copy form') }} + + + {{ t('forms', 'Download form') }} + @@ -49,7 +50,7 @@ export default defineComponent({ }, }, - emits: ['update:open', 'clone'], + emits: ['update:open', 'clone', 'download'], data() { return { @@ -74,6 +75,10 @@ export default defineComponent({ this.$emit('update:open', false) }, + onDownloadForm(formId) { + this.$emit('download', formId) + }, + onDelete(form) { this.shownForms = this.shownForms.filter(({ id }) => id !== form.id) },