Skip to content

Commit 87a5043

Browse files
committed
feat: add import function (#1425)
Signed-off-by: TimedIn <git@timedin.net>
1 parent acd3f4c commit 87a5043

7 files changed

Lines changed: 571 additions & 24 deletions

File tree

lib/Controller/ApiController.php

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ public function getForms(string $type = 'owned'): DataResponse {
148148
* Return a copy of the form if the parameter $fromId is set
149149
*
150150
* @param ?int $fromId (optional) Id of the form that should be cloned
151+
* @param ?bool $import (optional) If it should import the form from post body
152+
* @param ?array<string, mixed> $formData (optional) The formdata to import
151153
* @return DataResponse<Http::STATUS_CREATED, FormsForm, array{}>
152154
* @throws OCSForbiddenException The user is not allowed to create forms
153155
*
@@ -157,14 +159,19 @@ public function getForms(string $type = 'owned'): DataResponse {
157159
#[NoAdminRequired()]
158160
#[BruteForceProtection(action: 'form')]
159161
#[ApiRoute(verb: 'POST', url: '/api/v3/forms')]
160-
public function newForm(?int $fromId = null): DataResponse {
162+
public function newForm(?int $fromId = null, ?bool $import = false, ?array $formData = []): DataResponse {
161163
// Check if user is allowed
162164
if (!$this->configService->canCreateForms()) {
163165
$this->logger->debug('This user is not allowed to create Forms.');
164166
throw new OCSForbiddenException('This user is not allowed to create Forms.');
165167
}
166168

167-
if ($fromId === null) {
169+
// Validate mutually exclusive parameters
170+
if ($fromId !== null && $import === true) {
171+
throw new OCSBadRequestException('Cannot use both fromId and import parameters');
172+
}
173+
174+
if ($fromId === null && $import === false) {
168175
// Create Form
169176
$form = new Form();
170177
$form->setOwnerId($this->currentUser->getUID());
@@ -183,10 +190,24 @@ public function newForm(?int $fromId = null): DataResponse {
183190

184191
$this->formMapper->insert($form);
185192
} else {
186-
$oldForm = $this->formsService->getFormIfAllowed($fromId, Constants::PERMISSION_EDIT);
193+
// Fill variables from json or database
194+
if ($import) {
195+
if (!isset($formData['questions']) || !\is_array($formData['questions'])) {
196+
throw new OCSBadRequestException('Invalid form data: missing questions');
197+
}
198+
$questions = $formData['questions'];
199+
$oldConfirmationEmailQuestionId = $formData['confirmationEmailQuestionId'] ?? null;
200+
unset($formData['questions']);
201+
} else {
202+
$oldForm = $this->formsService->getFormIfAllowed($fromId, Constants::PERMISSION_EDIT);
187203

188-
// Read old form, (un)set new form specific data, extend title
189-
$formData = $oldForm->read();
204+
// Read old form, (un)set new form specific data, extend title
205+
$formData = $oldForm->read();
206+
// Get Questions, set new formId, reinsert
207+
$questions = $this->questionMapper->findByForm($oldForm->getId());
208+
$oldConfirmationEmailQuestionId = $oldForm->getConfirmationEmailQuestionId();
209+
}
210+
// Remove unused data
190211
unset($formData['id']);
191212
unset($formData['created']);
192213
unset($formData['lastUpdated']);
@@ -199,7 +220,9 @@ public function newForm(?int $fromId = null): DataResponse {
199220
$formData['ownerId'] = $this->currentUser->getUID();
200221
$formData['hash'] = $this->formsService->generateFormHash();
201222
// TRANSLATORS Appendix to the form Title of a duplicated/copied form.
202-
$formData['title'] .= ' - ' . $this->l10n->t('Copy');
223+
if (!$import) {
224+
$formData['title'] .= ' - ' . $this->l10n->t('Copy');
225+
}
203226
$formData['access'] = [
204227
'permitAllUsers' => false,
205228
'showToAllUsers' => false,
@@ -213,26 +236,35 @@ public function newForm(?int $fromId = null): DataResponse {
213236
$form = Form::fromParams($formData);
214237
$this->formMapper->insert($form);
215238

216-
// Get Questions, set new formId, reinsert
217-
$questions = $this->questionMapper->findByForm($oldForm->getId());
218-
$oldConfirmationEmailQuestionId = $oldForm->getConfirmationEmailQuestionId();
219-
220239
foreach ($questions as $oldQuestion) {
221-
$questionData = $oldQuestion->read();
240+
if ($import) {
241+
if (!isset($oldQuestion['id'])) {
242+
throw new OCSBadRequestException('Invalid question data: missing id');
243+
}
244+
$questionData = $oldQuestion;
245+
$oldQuestionId = $oldQuestion['id'] ?? [];
246+
$options = $oldQuestion['options'];
247+
} else {
248+
$questionData = $oldQuestion->read();
249+
$oldQuestionId = $oldQuestion->getId();
250+
// Get Options, set new QuestionId, reinsert
251+
$options = $this->optionMapper->findByQuestion($oldQuestionId);
252+
}
222253

223254
unset($questionData['id']);
255+
unset($questionData['options']);
256+
unset($questionData['accept']);
257+
224258
$questionData['formId'] = $form->getId();
225259
$newQuestion = Question::fromParams($questionData);
226260
$this->questionMapper->insert($newQuestion);
227261

228-
if (isset($oldConfirmationEmailQuestionId) && $oldConfirmationEmailQuestionId === $oldQuestion->getId()) {
262+
if (isset($oldConfirmationEmailQuestionId) && $oldConfirmationEmailQuestionId === $oldQuestionId) {
229263
$form->setConfirmationEmailQuestionId($newQuestion->getId());
230264
}
231265

232-
// Get Options, set new QuestionId, reinsert
233-
$options = $this->optionMapper->findByQuestion($oldQuestion->getId());
234266
foreach ($options as $oldOption) {
235-
$optionData = $oldOption->read();
267+
$optionData = $import ? $oldOption : $oldOption->read();
236268

237269
unset($optionData['id']);
238270
$optionData['questionId'] = $newQuestion->getId();
@@ -694,8 +726,10 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair
694726
throw new OCSBadRequestException('Invalid extraSettings, will not update.');
695727
}
696728

697-
if ($form->getConfirmationEmailQuestionId() === $question->getId()
698-
&& !$question->isEmailType($keyValuePairs['type'] ?? null, $keyValuePairs['extraSettings'] ?? null)) {
729+
if (
730+
$form->getConfirmationEmailQuestionId() === $question->getId()
731+
&& !$question->isEmailType($keyValuePairs['type'] ?? null, $keyValuePairs['extraSettings'] ?? null)
732+
) {
699733
$form->setConfirmationEmailQuestionId(null);
700734
}
701735

openapi.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,21 @@
869869
"nullable": true,
870870
"default": null,
871871
"description": "(optional) Id of the form that should be cloned"
872+
},
873+
"import": {
874+
"type": "boolean",
875+
"nullable": true,
876+
"default": false,
877+
"description": "(optional) If it should import the form from post body"
878+
},
879+
"formData": {
880+
"type": "object",
881+
"nullable": true,
882+
"default": {},
883+
"description": "(optional) The formdata to import",
884+
"additionalProperties": {
885+
"type": "object"
886+
}
872887
}
873888
}
874889
}

package-lock.json

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"markdown-it": "^14.2.0",
4646
"p-queue": "^9.3.0",
4747
"qrcode": "^1.5.4",
48+
"semver": "^7.8.2",
4849
"vue": "^3.5.22",
4950
"vue-draggable-plus": "^0.6.1",
5051
"vue-router": "^4.6.4"

playwright/e2e/download-import-form.spec.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,199 @@ test.describe('Download form', () => {
131131
expect(json.form.questions[1].options).toHaveLength(3)
132132
})
133133
})
134+
135+
test.describe('Import form', () => {
136+
test.beforeEach(async ({ page }) => {
137+
await page.goto('apps/forms', { waitUntil: 'networkidle' })
138+
await page.waitForURL(/apps\/forms\/$/)
139+
})
140+
141+
test('Import a form from JSON file', async ({ page, appNavigation, form }) => {
142+
// Create a form first so the import button is visible
143+
await appNavigation.clickNewForm()
144+
await form.fillTitle('Source form')
145+
146+
// Prepare JSON data matching the export format
147+
const formData = {
148+
appVersion: '5.3.0-rc.0',
149+
form: {
150+
title: 'Imported test form',
151+
description: 'Description',
152+
questions: [
153+
{
154+
id: 52,
155+
formId: 62,
156+
order: 1,
157+
type: 'multiple',
158+
isRequired: false,
159+
text: 'Checkbox',
160+
name: '',
161+
description: '',
162+
extraSettings: [],
163+
options: [
164+
{
165+
id: 50,
166+
questionId: 52,
167+
order: 1,
168+
text: 'A',
169+
optionType: 'choice',
170+
},
171+
{
172+
id: 51,
173+
questionId: 52,
174+
order: 2,
175+
text: 'B',
176+
optionType: 'choice',
177+
},
178+
],
179+
},
180+
{
181+
id: 53,
182+
formId: 62,
183+
order: 2,
184+
type: 'short',
185+
isRequired: false,
186+
text: 'Text',
187+
name: '',
188+
description: '',
189+
extraSettings: [],
190+
options: [],
191+
},
192+
],
193+
},
194+
}
195+
196+
const jsonContent = JSON.stringify(formData)
197+
198+
// Set up file chooser handler before clicking import
199+
const fileChooserPromise = page.waitForEvent('filechooser')
200+
201+
// Click the Import form button in the navigation
202+
await page.getByRole('button', { name: 'Import form' }).click()
203+
204+
const fileChooser = await fileChooserPromise
205+
await fileChooser.setFiles({
206+
name: 'imported-form.json',
207+
mimeType: 'application/json',
208+
buffer: Buffer.from(jsonContent),
209+
})
210+
211+
// Wait for the imported form to appear in the navigation
212+
await expect(appNavigation.getOwnForm('Imported test form')).toBeVisible({
213+
timeout: 10000,
214+
})
215+
216+
await expect(
217+
page.getByRole('textbox', { name: 'Title of question number 1' }),
218+
).toHaveValue('Checkbox')
219+
await expect(
220+
page.getByRole('textbox', { name: 'Description', exact: true }),
221+
).toHaveValue('Description')
222+
await expect(
223+
page.getByRole('textbox', { name: 'The text of option 1' }),
224+
).toHaveValue('A')
225+
await expect(
226+
page.getByRole('textbox', { name: 'The text of option 2' }),
227+
).toHaveValue('B')
228+
await expect(
229+
page.getByRole('textbox', { name: 'Title of question number 2' }),
230+
).toHaveValue('Text')
231+
})
232+
233+
test('Import a form with a long text question', async ({
234+
page,
235+
appNavigation,
236+
form,
237+
}) => {
238+
await appNavigation.clickNewForm()
239+
await form.fillTitle('Source form')
240+
241+
const formData = {
242+
appVersion: '5.3.0-rc.0',
243+
form: {
244+
title: 'Long answer form',
245+
description: 'Testing long text import',
246+
questions: [
247+
{
248+
id: 10,
249+
formId: 1,
250+
order: 1,
251+
type: 'long',
252+
text: 'Your biography',
253+
isRequired: true,
254+
options: [],
255+
},
256+
],
257+
},
258+
}
259+
260+
const jsonContent = JSON.stringify(formData)
261+
262+
const fileChooserPromise = page.waitForEvent('filechooser')
263+
264+
// Click the Import form button in the navigation
265+
await page.getByRole('button', { name: 'Import form' }).click()
266+
267+
const fileChooser = await fileChooserPromise
268+
await fileChooser.setFiles({
269+
name: 'long-text.json',
270+
mimeType: 'application/json',
271+
buffer: Buffer.from(jsonContent),
272+
})
273+
274+
await expect(appNavigation.getOwnForm('Long answer form')).toBeVisible()
275+
await expect(
276+
page.getByRole('textbox', { name: 'Title of question number 1' }),
277+
).toHaveValue('Your biography')
278+
await expect(
279+
page.getByRole('textbox', { name: 'Description', exact: true }),
280+
).toHaveValue('Testing long text import')
281+
})
282+
283+
test('Import a form with confirmation email question remapping', async ({
284+
page,
285+
appNavigation,
286+
form,
287+
}) => {
288+
await appNavigation.clickNewForm()
289+
await form.fillTitle('Source form')
290+
291+
const formData = {
292+
appVersion: '5.3.0-rc.0',
293+
form: {
294+
title: 'Email confirmation',
295+
description: '',
296+
confirmationEmailQuestionId: 99,
297+
questions: [
298+
{
299+
id: 99,
300+
formId: 1,
301+
order: 1,
302+
type: 'short',
303+
text: 'Your email',
304+
options: [],
305+
},
306+
],
307+
},
308+
}
309+
310+
const jsonContent = JSON.stringify(formData)
311+
312+
const fileChooserPromise = page.waitForEvent('filechooser')
313+
314+
// Click the Import form button in the navigation
315+
await page.getByRole('button', { name: 'Import form' }).click()
316+
317+
const fileChooser = await fileChooserPromise
318+
await fileChooser.setFiles({
319+
name: 'email-confirm.json',
320+
mimeType: 'application/json',
321+
buffer: Buffer.from(jsonContent),
322+
})
323+
324+
await expect(appNavigation.getOwnForm('Email confirmation')).toBeVisible()
325+
await expect(
326+
page.getByRole('textbox', { name: 'Title of question number 1' }),
327+
).toHaveValue('Your email')
328+
})
329+
})

0 commit comments

Comments
 (0)