Skip to content

Commit d7123e2

Browse files
committed
feat: exporting forms as json
Signed-off-by: TimedIn <git@timedin.net>
1 parent 3ecbdeb commit d7123e2

4 files changed

Lines changed: 223 additions & 17 deletions

File tree

lib/Controller/ApiController.php

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
use OCA\Forms\Db\UploadedFile;
2424
use OCA\Forms\Db\UploadedFileMapper;
2525
use OCA\Forms\Exception\NoSuchFormException;
26+
use OCA\Forms\Helper\FilePathHelper;
2627
use OCA\Forms\ResponseDefinitions;
2728
use OCA\Forms\Service\ConfigService;
2829
use OCA\Forms\Service\ConfirmationEmailService;
2930
use OCA\Forms\Service\FormsService;
3031
use OCA\Forms\Service\SubmissionService;
32+
use OCP\App\IAppManager;
3133
use OCP\AppFramework\Db\DoesNotExistException;
3234
use OCP\AppFramework\Db\IMapperException;
3335
use OCP\AppFramework\Http;
@@ -76,6 +78,7 @@ public function __construct(
7678
IRequest $request,
7779
IUserSession $userSession,
7880
private readonly AnswerMapper $answerMapper,
81+
private readonly FilePathHelper $filePathHelper,
7982
private readonly FormMapper $formMapper,
8083
private readonly OptionMapper $optionMapper,
8184
private readonly QuestionMapper $questionMapper,
@@ -86,6 +89,7 @@ public function __construct(
8689
private readonly SubmissionService $submissionService,
8790
private readonly IL10N $l10n,
8891
private readonly LoggerInterface $logger,
92+
private readonly IAppManager $appManager,
8993
private readonly IUserManager $userManager,
9094
private readonly IRootFolder $rootFolder,
9195
private readonly UploadedFileMapper $uploadedFileMapper,
@@ -251,7 +255,8 @@ public function newForm(?int $fromId = null): DataResponse {
251255
* Read all information to edit a Form (form, questions, options, except submissions/answers)
252256
*
253257
* @param int $formId Id of the form
254-
* @return DataResponse<Http::STATUS_OK, FormsForm, array{}>
258+
* @param ?bool $export if the form should get exported
259+
* @return DataResponse<Http::STATUS_OK, FormsForm, array{}>|DataDownloadResponse<Http::STATUS_OK, 'application/json', array{}>
255260
* @throws OCSBadRequestException Could not find form
256261
* @throws OCSForbiddenException User has no permissions to get this form
257262
*
@@ -261,9 +266,40 @@ public function newForm(?int $fromId = null): DataResponse {
261266
#[NoAdminRequired()]
262267
#[BruteForceProtection(action: 'form')]
263268
#[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}')]
264-
public function getForm(int $formId): DataResponse {
269+
public function getForm(int $formId, ?bool $export): DataResponse|DataDownloadResponse {
265270
$form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_SUBMIT);
266271

272+
if ($export) {
273+
$formData = $this->formsService->getPublicForm($form);
274+
unset($formData['hash']);
275+
unset($formData['created']);
276+
unset($formData['lastUpdated']);
277+
unset($formData['lockedBy']);
278+
unset($formData['lockedUntil']);
279+
unset($formData['permissions']);
280+
unset($formData['canSubmit']);
281+
unset($formData['isMaxSubmissionsReached']);
282+
unset($formData['submissionCount']);
283+
unset($formData['filePath']);
284+
unset($formData['state']);
285+
unset($formData['id']);
286+
287+
foreach ($formData['questions'] as &$question) {
288+
unset($question['formId']);
289+
unset($question['accept']);
290+
foreach ($question['options'] as &$option) {
291+
unset($option['questionId']);
292+
unset($option['id']);
293+
}
294+
}
295+
296+
$exportFile = ['form' => $formData,'appVersion' => $this->appManager->getAppVersion('forms')];
297+
$fileName = $this->filePathHelper->normalizeFileName($form->getTitle() . '.json');
298+
299+
return new DataDownloadResponse(
300+
json_encode($exportFile), $fileName, 'application/json');
301+
}
302+
267303
return new DataResponse($this->formsService->getForm($form));
268304
}
269305

openapi.json

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,15 @@
10171017
"format": "int64"
10181018
}
10191019
},
1020+
{
1021+
"name": "export",
1022+
"in": "query",
1023+
"description": "if the form should get exported",
1024+
"schema": {
1025+
"type": "boolean",
1026+
"nullable": true
1027+
}
1028+
},
10201029
{
10211030
"name": "OCS-APIRequest",
10221031
"in": "header",
@@ -1034,27 +1043,35 @@
10341043
"content": {
10351044
"application/json": {
10361045
"schema": {
1037-
"type": "object",
1038-
"required": [
1039-
"ocs"
1040-
],
1041-
"properties": {
1042-
"ocs": {
1046+
"anyOf": [
1047+
{
10431048
"type": "object",
10441049
"required": [
1045-
"meta",
1046-
"data"
1050+
"ocs"
10471051
],
10481052
"properties": {
1049-
"meta": {
1050-
"$ref": "#/components/schemas/OCSMeta"
1051-
},
1052-
"data": {
1053-
"$ref": "#/components/schemas/Form"
1053+
"ocs": {
1054+
"type": "object",
1055+
"required": [
1056+
"meta",
1057+
"data"
1058+
],
1059+
"properties": {
1060+
"meta": {
1061+
"$ref": "#/components/schemas/OCSMeta"
1062+
},
1063+
"data": {
1064+
"$ref": "#/components/schemas/Form"
1065+
}
1066+
}
10541067
}
10551068
}
1069+
},
1070+
{
1071+
"type": "string",
1072+
"format": "binary"
10561073
}
1057-
}
1074+
]
10581075
}
10591076
}
10601077
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect, mergeTests } from '@playwright/test'
7+
import { test as formTest } from '../support/fixtures/form.ts'
8+
import { test as appNavigationTest } from '../support/fixtures/navigation.ts'
9+
import { test as randomUserTest } from '../support/fixtures/random-user.ts'
10+
import { QuestionType } from '../support/sections/QuestionType.ts'
11+
12+
const test = mergeTests(randomUserTest, appNavigationTest, formTest)
13+
14+
test.describe('Export form', () => {
15+
test.beforeEach(async ({ page }) => {
16+
await page.goto('apps/forms', { waitUntil: 'networkidle' })
17+
await page.waitForURL(/apps\/forms\/$/)
18+
})
19+
20+
test('Export a form as JSON file', async ({ page, appNavigation, form }) => {
21+
await appNavigation.clickNewForm()
22+
await form.fillTitle('Export test')
23+
await page.getByRole('button', { name: 'Add a question' }).click()
24+
await page.getByRole('menuitem', { name: 'Checkboxes' }).click()
25+
await page
26+
.getByRole('textbox', { name: 'Title of question number 1' })
27+
.fill('A')
28+
await page
29+
.getByRole('listitem', { name: 'Question number 1' })
30+
.getByPlaceholder('Add a new answer option')
31+
.fill('B')
32+
await page
33+
.getByRole('listitem', { name: 'Question number 1' })
34+
.getByPlaceholder('Add a new answer option')
35+
.press('Enter')
36+
await page.waitForTimeout(100)
37+
38+
await page
39+
.getByRole('listitem', { name: 'Question number 1' })
40+
.getByPlaceholder('Add a new answer option')
41+
.fill('C')
42+
await page
43+
.getByRole('listitem', { name: 'Question number 1' })
44+
.getByPlaceholder('Add a new answer option')
45+
.press('Enter')
46+
await page.waitForTimeout(100)
47+
48+
await page
49+
.getByRole('listitem', { name: 'Question number 1' })
50+
.getByPlaceholder('Add a new answer option')
51+
.fill('D')
52+
await page
53+
.getByRole('listitem', { name: 'Question number 1' })
54+
.getByPlaceholder('Add a new answer option')
55+
.press('Enter')
56+
await page.waitForTimeout(100)
57+
58+
const downloadPromise = page.waitForEvent('download')
59+
60+
// Hover over the form to make the actions button visible
61+
await appNavigation.getOwnForm('Download test').hover()
62+
await page.getByRole('button', { name: 'Form actions' }).click()
63+
64+
// Click Export form in the popover menu
65+
await page.getByRole('menuitem', { name: 'Export form' }).click()
66+
67+
const download = await downloadPromise
68+
expect(download.suggestedFilename()).toMatch(/^Download test.*\.json$/)
69+
70+
const stream = await download.createReadStream()
71+
const json = await new Promise<any>((resolve, reject) => {
72+
let raw = ''
73+
stream.on('data', (chunk) => (raw += chunk))
74+
stream.on('end', () => resolve(JSON.parse(raw)))
75+
stream.on('error', reject)
76+
})
77+
expect(json.form).toBeDefined()
78+
expect(json.form.questions).toHaveLength(1)
79+
expect(json.appVersion).toBeDefined()
80+
// Deepen: verify the JSON content matches what we created
81+
expect(json.form.title).toBe('Download test')
82+
expect(json.form.questions[0].type).toBe('multiple')
83+
expect(json.form.questions[0].text).toBe('A')
84+
expect(json.form.questions[0].options).toHaveLength(3)
85+
expect(json.form.questions[0].options[0].text).toBe('B')
86+
expect(json.form.questions[0].options[1].text).toBe('C')
87+
expect(json.form.questions[0].options[2].text).toBe('D')
88+
// Verify stripped fields are absent
89+
expect(json.form.id).toBeUndefined()
90+
expect(json.form.hash).toBeUndefined()
91+
expect(json.form.ownerId).toBeUndefined()
92+
})
93+
94+
test('Download a form with multiple question types', async ({
95+
page,
96+
appNavigation,
97+
form,
98+
}) => {
99+
await appNavigation.clickNewForm()
100+
await form.fillTitle('Multi type form')
101+
await form.addQuestion(QuestionType.ShortAnswer)
102+
const q1 = await form.getQuestions()
103+
await q1[0].fillTitle('Name')
104+
await form.addQuestion(QuestionType.Dropdown)
105+
const q2 = await form.getQuestions()
106+
await q2[1].fillTitle('Country')
107+
await q2[1].addAnswer('DE')
108+
await q2[1].addAnswer('FR')
109+
await q2[1].addAnswer('IT')
110+
111+
await page.waitForLoadState('networkidle')
112+
const downloadPromise = page.waitForEvent('download')
113+
114+
await appNavigation.getOwnForm('Multi type form').hover()
115+
await page.getByRole('button', { name: 'Form actions' }).click()
116+
await page.getByRole('menuitem', { name: 'Export form' }).click()
117+
118+
const download = await downloadPromise
119+
const stream = await download.createReadStream()
120+
const json = await new Promise<any>((resolve, reject) => {
121+
let raw = ''
122+
stream.on('data', (chunk) => (raw += chunk))
123+
stream.on('end', () => resolve(JSON.parse(raw)))
124+
stream.on('error', reject)
125+
})
126+
expect(json.form.questions).toHaveLength(2)
127+
expect(json.form.questions[0].type).toBe('short')
128+
expect(json.form.questions[0].text).toBe('Name')
129+
expect(json.form.questions[1].type).toBe('dropdown')
130+
expect(json.form.questions[1].text).toBe('Country')
131+
expect(json.form.questions[1].options).toHaveLength(3)
132+
})
133+
})

src/components/AppNavigationForm.vue

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@
6464
</template>
6565
{{ t('forms', 'Copy form') }}
6666
</NcActionButton>
67+
<NcActionButton v-if="canEdit" closeAfterClick @click="onExportForm">
68+
<template #icon>
69+
<NcIconSvgWrapper :svg="IconDownload" />
70+
</template>
71+
{{ t('forms', 'Export form') }}
72+
</NcActionButton>
6773
<NcActionSeparator v-if="canEdit && !readOnly" />
6874
<NcActionButton
6975
v-if="canEdit && !readOnly"
@@ -106,10 +112,11 @@ import IconPoll from '@material-symbols/svg-400/outlined/bar_chart.svg?raw'
106112
import IconCheck from '@material-symbols/svg-400/outlined/check.svg?raw'
107113
import IconContentCopy from '@material-symbols/svg-400/outlined/content_copy.svg?raw'
108114
import IconDelete from '@material-symbols/svg-400/outlined/delete.svg?raw'
115+
import IconDownload from '@material-symbols/svg-400/outlined/download.svg?raw'
109116
import IconPencil from '@material-symbols/svg-400/outlined/edit.svg?raw'
110117
import IconShareVariant from '@material-symbols/svg-400/outlined/share.svg?raw'
111118
import IconArchiveOff from '@material-symbols/svg-400/outlined/unarchive.svg?raw'
112-
import { getCurrentUser } from '@nextcloud/auth'
119+
import { getCurrentUser, getRequestToken } from '@nextcloud/auth'
113120
import axios from '@nextcloud/axios'
114121
import { showConfirmation, showError } from '@nextcloud/dialogs'
115122
import { t } from '@nextcloud/l10n'
@@ -275,6 +282,17 @@ export default defineComponent({
275282
emit('clone', props.form.id)
276283
}
277284
285+
const onExportForm = () => {
286+
const downloadUrl =
287+
generateOcsUrl('apps/forms/api/v3/forms/{id}', {
288+
id: props.form.id,
289+
})
290+
+ '?requesttoken='
291+
+ encodeURIComponent(getRequestToken() ?? '')
292+
+ '&export=true'
293+
window.open(downloadUrl, '_self')
294+
}
295+
278296
const onDeleteForm = async (): Promise<void> => {
279297
loading.value = true
280298
try {
@@ -349,6 +367,7 @@ export default defineComponent({
349367
IconCheck,
350368
IconContentCopy,
351369
IconDelete,
370+
IconDownload,
352371
IconPencil,
353372
IconPoll,
354373
IconShareVariant,
@@ -364,6 +383,7 @@ export default defineComponent({
364383
hasSubtitle,
365384
routerTarget,
366385
mobileCloseNavigation,
386+
onExportForm,
367387
onShareForm,
368388
onCloneForm,
369389
onConfirmDelete,

0 commit comments

Comments
 (0)