Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions lib/FormsMigrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ public function export(IUser $user, IExportDestination $exportDestination, Outpu
// Do NOT unset ID of question here, as it is necessary for answers.
unset($formData['questions'][$qKey]['formId']);
foreach ($question['options'] as $oKey => $option) {
unset($formData['questions'][$qKey]['options'][$oKey]['id']);
unset($formData['questions'][$qKey]['options'][$oKey]['questionId']);
}
}
Expand Down Expand Up @@ -164,6 +163,8 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
$this->formMapper->insert($form);

$questionIdMap = [];
$questionTypeMap = [];
$optionIdMap = [];
foreach ($formData['questions'] as $questionData) {
$question = new Question();
$question->setFormId($form->getId());
Expand All @@ -178,13 +179,20 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface

// Store QuestionId to map Answers.
$questionIdMap[$questionData['id']] = $question->getId();
$questionTypeMap[$questionData['id']] = $questionData['type'];

foreach ($questionData['options'] as $optionData) {
$option = new Option();
$option->setQuestionId($question->getId());
$option->setText($optionData['text']);
$option->setOrder($optionData['order'] ?? null);
$option->setOptionType($optionData['optionType'] ?? null);

$this->optionMapper->insert($option);

if (isset($optionData['id'])) {
$optionIdMap[$optionData['id']] = $option->getId();
}
}
}

Expand All @@ -210,7 +218,11 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
$answer = new Answer();
$answer->setSubmissionId($submission->getId());
$answer->setQuestionId($questionIdMap[$answerData['questionId']]);
$answer->setText($answerData['text']);
$answerText = $answerData['text'];
if ($optionIdMap !== [] && in_array($questionTypeMap[$answerData['questionId']], [Constants::ANSWER_TYPE_GRID, Constants::ANSWER_TYPE_RANKING], true)) {
$answerText = $this->mapOptionIdsOfAnswer($answerText, $optionIdMap);
}
$answer->setText($answerText);

$this->answerMapper->insert($answer);
}
Expand All @@ -221,6 +233,40 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
}
}

/**
* Replaces the exported option ids in the answer of a grid or ranking question with the ids of the imported options
*
* Ranking answers are a list of option ids. Grid answers map row ids to a column id, a list of column ids, or column ids with numbers.
*
* @param string $answerText JSON answer as stored
* @param array<int, int> $optionIdMap exported option id to imported option id
* @return string JSON answer with the imported option ids, or the text unchanged if it is no JSON array
*/
private function mapOptionIdsOfAnswer(string $answerText, array $optionIdMap): string {
$value = json_decode($answerText, true);
if (!is_array($value) || $value === []) {
return $answerText;
}

$map = static fn (mixed $id): mixed => (is_int($id) || is_string($id)) ? ($optionIdMap[$id] ?? $id) : $id;
if (array_is_list($value)) {
return json_encode(array_map($map, $value));
}

$mapped = [];
foreach ($value as $rowId => $cell) {
if (is_array($cell) && array_is_list($cell)) {
$cell = array_map($map, $cell);
} elseif (is_array($cell)) {
$cell = array_combine(array_map($map, array_keys($cell)), $cell);
} else {
$cell = $map($cell);
}
$mapped[$map($rowId)] = $cell;
}
return json_encode($mapped);
}

/**
* Unique AppID
*/
Expand Down
82 changes: 82 additions & 0 deletions tests/Unit/FormsMigratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use OCA\Forms\Db\AnswerMapper;
use OCA\Forms\Db\Form;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\Option;
use OCA\Forms\Db\OptionMapper;
use OCA\Forms\Db\QuestionMapper;
use OCA\Forms\Db\SubmissionMapper;
Expand Down Expand Up @@ -109,6 +110,7 @@ public static function dataExport() {
"extraSettings": {},
"options": [
{
"id": 35,
"text": "ans1"
}
]
Expand Down Expand Up @@ -334,6 +336,86 @@ public function testImportSkipsAnswersOfQuestionsNotExported(): void {
$this->formsMigrator->import($user, $importSource, $this->createMock(OutputInterface::class));
}

public static function dataImportMapsOptionsOfGridAndRankingQuestions(): array {
return [
'current archive' => [
'options' => [
['id' => 11, 'order' => 1, 'text' => 'Row', 'optionType' => 'row'],
['id' => 12, 'order' => 1, 'text' => 'Yes', 'optionType' => 'column'],
['id' => 13, 'order' => 2, 'text' => 'No', 'optionType' => 'column'],
],
'rankingOptions' => [
['id' => 21, 'order' => 1, 'text' => 'A', 'optionType' => 'choice'],
['id' => 22, 'order' => 2, 'text' => 'B', 'optionType' => 'choice'],
],
'expectedOptions' => [[1, 'row'], [1, 'column'], [2, 'column'], [1, 'choice'], [2, 'choice']],
'expectedAnswers' => ['{"500":501}', '{"500":[501,502]}', '{"500":{"501":12,"502":"13"}}', '[504,503]'],
],
'archive without option ids, order and type' => [
'options' => [['text' => 'Row'], ['text' => 'Yes'], ['text' => 'No']],
'rankingOptions' => [['text' => 'A'], ['text' => 'B']],
'expectedOptions' => [[null, null], [null, null], [null, null], [null, null], [null, null]],
'expectedAnswers' => ['{"11":12}', '{"11":[12,13]}', '{"11":{"12":12,"13":"13"}}', '[22,21]'],
],
];
}

/**
* @param list<array<string, mixed>> $options exported options of the grid question
* @param list<array<string, mixed>> $rankingOptions exported options of the ranking question
* @param list<array{0: ?int, 1: ?string}> $expectedOptions order and type of each imported option
* @param list<string> $expectedAnswers texts of the imported answers
*/
#[DataProvider('dataImportMapsOptionsOfGridAndRankingQuestions')]
public function testImportMapsOptionsOfGridAndRankingQuestions(array $options, array $rankingOptions, array $expectedOptions, array $expectedAnswers): void {
$question = ['isRequired' => false, 'description' => '', 'extraSettings' => []];
$formData = [[
'title' => 'Grid', 'description' => '', 'access' => ['permitAllUsers' => false, 'showToAllUsers' => false], 'expires' => 0,
'isAnonymous' => false, 'submitMultiple' => false, 'allowEditSubmissions' => false, 'showExpiration' => false,
'questions' => [
$question + ['id' => 1, 'order' => 1, 'type' => 'grid', 'text' => 'Grid', 'options' => $options],
$question + ['id' => 2, 'order' => 2, 'type' => 'ranking', 'text' => 'Ranking', 'options' => $rankingOptions],
],
'submissions' => [[
'userId' => 'anyUser@localhost', 'timestamp' => 1651354059,
'answers' => [
['questionId' => 1, 'text' => '{"11":12}'],
['questionId' => 1, 'text' => '{"11":[12,13]}'],
['questionId' => 1, 'text' => '{"11":{"12":12,"13":"13"}}'],
['questionId' => 2, 'text' => '[22,21]'],
],
]],
]];

$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('someUser');
$importSource = $this->createMock(IImportSource::class);
$importSource->method('getMigratorVersion')->willReturn(1);
$importSource->method('getFileContents')->willReturn(json_encode($formData));
$questionId = 100;
$this->questionMapper->method('insert')->willReturnCallback(function ($question) use (&$questionId) {
$question->setId($questionId++);
return $question;
});
$optionId = 500;
$insertedOptions = [];
$this->optionMapper->method('insert')->willReturnCallback(function (Option $option) use (&$optionId, &$insertedOptions) {
$option->setId($optionId++);
$insertedOptions[] = [$option->getOrder(), $option->getOptionType()];
return $option;
});
$insertedAnswers = [];
$this->answerMapper->method('insert')->willReturnCallback(function ($answer) use (&$insertedAnswers) {
$insertedAnswers[] = $answer->getText();
return $answer;
});

$this->formsMigrator->import($user, $importSource, $this->createMock(OutputInterface::class));

$this->assertSame($expectedOptions, $insertedOptions);
$this->assertSame($expectedAnswers, $insertedAnswers);
}

public function testImport_NoVersion() {
$user = $this->createMock(IUser::class);
$importSource = $this->createMock(IImportSource::class);
Expand Down
Loading