Skip to content

Commit acd3f4c

Browse files
committed
feat: downloading forms (#1425)
Signed-off-by: TimedIn <git@timedin.net>
1 parent ed2d520 commit acd3f4c

4 files changed

Lines changed: 210 additions & 3 deletions

File tree

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('Download form', () => {
15+
test.beforeEach(async ({ page }) => {
16+
await page.goto('apps/forms', { waitUntil: 'networkidle' })
17+
await page.waitForURL(/apps\/forms\/$/)
18+
})
19+
20+
test('Download a form as JSON file', async ({ page, appNavigation, form }) => {
21+
await appNavigation.clickNewForm()
22+
await form.fillTitle('Download 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 Download form in the popover menu
65+
await page.getByRole('menuitem', { name: 'Download 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: 'Download 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/Forms.vue

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
@openSharing="openSharing"
3333
@mobileCloseNavigation="mobileCloseNavigation"
3434
@clone="onCloneForm"
35+
@download="onDownloadForm"
3536
@delete="onDeleteForm" />
3637
</ul>
3738
</template>
@@ -51,6 +52,7 @@
5152
readOnly
5253
@openSharing="openSharing"
5354
@clone="onCloneForm"
55+
@download="onDownloadForm"
5456
@mobileCloseNavigation="mobileCloseNavigation" />
5557
</ul>
5658
</template>
@@ -141,7 +143,8 @@
141143
<ArchivedFormsModal
142144
v-model:open="showArchivedForms"
143145
:forms="archivedForms"
144-
@clone="onCloneForm" />
146+
@clone="onCloneForm"
147+
@download="onDownloadForm" />
145148
</NcContent>
146149
</template>
147150

@@ -170,6 +173,7 @@ import AppNavigationForm from './components/AppNavigationForm.vue'
170173
import ArchivedFormsModal from './components/ArchivedFormsModal.vue'
171174
import Sidebar from './views/Sidebar.vue'
172175
import FormsIcon from '../img/forms-dark.svg?raw'
176+
import { version } from '../package.json'
173177
import PermissionTypes from './mixins/PermissionTypes.js'
174178
import { FormState } from './models/Constants.ts'
175179
import logger from './utils/Logger.js'
@@ -440,6 +444,58 @@ export default {
440444
}
441445
}
442446
447+
const onDownloadForm = async (id) => {
448+
try {
449+
const response = await axios.get(
450+
generateOcsUrl('apps/forms/api/v3/forms/{id}', {
451+
id,
452+
}),
453+
)
454+
const form = OcsResponse2Data(response)
455+
456+
// download only required values
457+
const download = {
458+
appVersion: version,
459+
form: {
460+
...form,
461+
// Remove unused values
462+
...[
463+
'hash',
464+
'ownerId',
465+
'created',
466+
'access',
467+
'lastUpdated',
468+
'lockedBy',
469+
'lockedUntil',
470+
'shares',
471+
'permissions',
472+
'canSubmit',
473+
'isMaxSubmissionsReached',
474+
'submissionCount',
475+
].reduce((prev, curr) => {
476+
prev[curr] = undefined
477+
return prev
478+
}, {}),
479+
480+
id: undefined,
481+
questions: form.questions,
482+
},
483+
}
484+
// create blob and download
485+
const blob = new Blob([JSON.stringify(download)])
486+
const url = URL.createObjectURL(blob)
487+
const a = document.createElement('a')
488+
a.href = url
489+
const formTitle = form.title ? form.title : t('forms', 'New form')
490+
a.download = `${formTitle}.json`
491+
a.click()
492+
URL.revokeObjectURL(url)
493+
} catch (error) {
494+
logger.error(`Unable to download form ${id}`, { error })
495+
showError(t('forms', 'Unable to download form'))
496+
}
497+
}
498+
443499
const onDeleteForm = async (id) => {
444500
const formIndex = forms.value.findIndex((form) => form.id === id)
445501
const deletedHash = forms.value[formIndex].hash
@@ -513,6 +569,7 @@ export default {
513569
fetchPartialForm,
514570
onNewForm,
515571
onCloneForm,
572+
onDownloadForm,
516573
onDeleteForm,
517574
onLastUpdatedByEventBus,
518575
IconPlus,

src/components/AppNavigationForm.vue

Lines changed: 13 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="onDownloadForm">
68+
<template #icon>
69+
<NcIconSvgWrapper :svg="IconDownload" />
70+
</template>
71+
{{ t('forms', 'Download form') }}
72+
</NcActionButton>
6773
<NcActionSeparator v-if="canEdit && !readOnly" />
6874
<NcActionButton
6975
v-if="canEdit && !readOnly"
@@ -103,6 +109,7 @@ import IconPoll from '@material-symbols/svg-400/outlined/bar_chart.svg?raw'
103109
import IconCheck from '@material-symbols/svg-400/outlined/check.svg?raw'
104110
import IconContentCopy from '@material-symbols/svg-400/outlined/content_copy.svg?raw'
105111
import IconDelete from '@material-symbols/svg-400/outlined/delete.svg?raw'
112+
import IconDownload from '@material-symbols/svg-400/outlined/download.svg?raw'
106113
import IconPencil from '@material-symbols/svg-400/outlined/edit.svg?raw'
107114
import IconShareVariant from '@material-symbols/svg-400/outlined/share.svg?raw'
108115
import IconArchiveOff from '@material-symbols/svg-400/outlined/unarchive.svg?raw'
@@ -154,7 +161,7 @@ export default {
154161
},
155162
},
156163
157-
emits: ['mobileCloseNavigation', 'openSharing', 'clone', 'delete'],
164+
emits: ['mobileCloseNavigation', 'openSharing', 'clone', 'delete', 'download'],
158165
159166
setup() {
160167
return {
@@ -164,6 +171,7 @@ export default {
164171
IconCheck,
165172
IconContentCopy,
166173
IconDelete,
174+
IconDownload,
167175
IconPencil,
168176
IconPoll,
169177
IconShareVariant,
@@ -292,6 +300,10 @@ export default {
292300
this.$emit('clone', this.form.id)
293301
},
294302
303+
onDownloadForm() {
304+
this.$emit('download', this.form.id)
305+
},
306+
295307
async onConfirmDelete() {
296308
const shouldDelete = await showConfirmation({
297309
name: t('forms', 'Delete form'),

src/components/ArchivedFormsModal.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
:form="form"
1818
forceDisplayActions
1919
@clone="onCloneForm(form.id)"
20+
@download="onDownloadForm(form.id)"
2021
@delete="onDelete(form)"
2122
@mobileCloseNavigation="$emit('update:open', false)" />
2223
</ul>
@@ -49,7 +50,7 @@ export default defineComponent({
4950
},
5051
},
5152
52-
emits: ['update:open', 'clone'],
53+
emits: ['update:open', 'clone', 'download'],
5354
5455
data() {
5556
return {
@@ -74,6 +75,10 @@ export default defineComponent({
7475
this.$emit('update:open', false)
7576
},
7677
78+
onDownloadForm(formId) {
79+
this.$emit('download', formId)
80+
},
81+
7782
onDelete(form) {
7883
this.shownForms = this.shownForms.filter(({ id }) => id !== form.id)
7984
},

0 commit comments

Comments
 (0)