Skip to content
Draft
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
120 changes: 120 additions & 0 deletions PULL_REQUEST.md
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this file from the PR

Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Add Section functionality to Nextcloud Forms

## Description

This PR adds a new "Section" element type to Nextcloud Forms that acts as a visual separator to group fields logically, without affecting data storage, validation, or submission logic.

## Functional Requirements

### 1. Section Creation
- New "Section" element type in the form editor
- Only parameter: section name (title) visible to users

### 2. Editor Display
- Sections appear as non-input elements
- Draggable for reordering
- Title displayed in bold/visually distinct style
- No description field for sections
- Bottom separator line for visual distinction

### 3. Form Filling Display
- Sections display as headings/subheadings at their position
- No influence on validation, mandatory fields, or submission flow

### 4. Export/View Responses Behavior
- Sections do not affect CSV export or response viewing
- Not stored in user responses

## Technical Implementation

### Backend Changes

#### Constants and Types
- **`lib/Constants.php`**: Added `ANSWER_TYPE_SECTION = 'section'` constant
- **`lib/ResponseDefinitions.php`**: Updated `FormsQuestionType` to include `"section"`

#### API Controller (`lib/Controller/ApiController.php`)
- **Section Validation**: Sections cannot be required, have options, or file uploads
- **Answer Storage**: Sections are filtered out from answer storage
- **Export Filtering**: Sections are excluded from submissions export
- **Form Cloning**: Sections maintain `isRequired = false` when cloning forms

#### Submission Service (`lib/Service/SubmissionService.php`)
- **Export Data**: Sections are filtered out from CSV/Excel export data
- **Validation**: Sections are ignored during submission validation

### Frontend Changes

#### Components
- **`src/components/Questions/QuestionSection.vue`**: New component for section rendering
- **`src/components/Questions/Question.vue`**: Updated to support section display and editing
- **`src/models/AnswerTypes.js`**: Added section type with appropriate icon and labels
- **`src/models/Constants.ts`**: Added `ANSWER_TYPE_SECTION` constant

#### Views
- **`src/views/Create.vue`**: Updated to pass question type to components
- **`src/views/Submit.vue`**: Updated to filter sections from validation and storage
- **`src/components/Results/ResultsSummary.vue`**: Updated to handle sections in results display
- **`src/components/Results/Submission.vue`**: Updated to filter sections from submission data

## Testing

### Backend Tests

#### ApiControllerTest.php
- `testUpdateQuestion_sectionCannotBeRequired()`: Verifies sections cannot be made required
- `testNewOption_sectionCannotHaveOptions()`: Verifies sections cannot have options
- `testUploadFiles_sectionCannotHaveFileUploads()`: Verifies sections cannot have file uploads
- `testGetSubmissions_sectionsAreFilteredOut()`: Verifies sections are filtered from export
- `testNewSubmission_sectionsAreNotStored()`: Verifies sections are not stored in answers

#### SubmissionServiceTest.php
- `testGetSubmissionsData_sectionsAreFilteredOut()`: Verifies sections are filtered from export data
- `testValidateSubmission_sectionsAreIgnored()`: Verifies sections are ignored in validation
- `testValidateSubmission_sectionsCannotBeRequired()`: Verifies sections cannot be required in validation

## Files Changed

### Backend Files
- `lib/Constants.php` - Added section constant
- `lib/ResponseDefinitions.php` - Updated type definitions
- `lib/Controller/ApiController.php` - Added section validation and filtering
- `lib/Service/SubmissionService.php` - Added export filtering

### Frontend Files
- `src/components/Questions/QuestionSection.vue` - New section component
- `src/components/Questions/Question.vue` - Updated for section support
- `src/models/AnswerTypes.js` - Added section type
- `src/models/Constants.ts` - Added section constant
- `src/views/Create.vue` - Updated component props
- `src/views/Submit.vue` - Updated validation and storage logic
- `src/components/Results/ResultsSummary.vue` - Updated results display
- `src/components/Results/Submission.vue` - Updated submission handling

### Test Files
- `tests/Unit/Controller/ApiControllerTest.php` - Added section tests
- `tests/Unit/Service/SubmissionServiceTest.php` - Added section tests

## Screenshots

### Before
- No section functionality available

### After
- Section element available in form editor
- Sections display as visual separators with titles
- Sections can be reordered like other form elements
- Sections do not appear in form submissions or exports

## Breaking Changes
None. This is a purely additive feature that does not affect existing functionality.

## Notes to Reviewers

- All hardcoded string literals have been replaced with constants (`Constants::ANSWER_TYPE_SECTION` on backend, `ANSWER_TYPE_SECTION` on frontend)
- Sections are completely filtered out from data storage and export to ensure they don't affect existing functionality
- Comprehensive test coverage has been added for all section-related functionality
- The implementation follows Nextcloud Forms coding standards and patterns

## Related Issues
This PR implements the section functionality as requested in the technical specification.
2 changes: 2 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class Constants {
public const ANSWER_TYPE_LONG = 'long';
public const ANSWER_TYPE_MULTIPLE = 'multiple';
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
public const ANSWER_TYPE_SECTION = 'section';
public const ANSWER_TYPE_SHORT = 'short';
public const ANSWER_TYPE_TIME = 'time';

Expand All @@ -89,6 +90,7 @@ class Constants {
self::ANSWER_TYPE_LONG,
self::ANSWER_TYPE_MULTIPLE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
self::ANSWER_TYPE_SECTION,
self::ANSWER_TYPE_SHORT,
self::ANSWER_TYPE_TIME,
];
Expand Down
56 changes: 56 additions & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ public function newForm(?int $fromId = null): DataResponse {

unset($questionData['id']);
$questionData['formId'] = $form->getId();

// Sections are never required
if ($questionData['type'] === Constants::ANSWER_TYPE_SECTION) {
$questionData['isRequired'] = false;
}

$newQuestion = Question::fromParams($questionData);
$this->questionMapper->insert($newQuestion);

Expand Down Expand Up @@ -558,6 +564,11 @@ public function newQuestion(int $formId, ?string $type = null, string $text = ''
unset($questionData['id']);
$questionData['order'] = end($allQuestions)->getOrder() + 1;

// Sections are never required
if ($questionData['type'] === Constants::ANSWER_TYPE_SECTION) {
$questionData['isRequired'] = false;
}

$newQuestion = Question::fromParams($questionData);
$this->questionMapper->insert($newQuestion);

Expand Down Expand Up @@ -658,6 +669,11 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair
throw new OCSBadRequestException('Invalid extraSettings, will not update.');
}

// Sections cannot be required
if (key_exists('isRequired', $keyValuePairs) && $question->getType() === Constants::ANSWER_TYPE_SECTION && $keyValuePairs['isRequired'] === true) {
throw new OCSBadRequestException('Sections cannot be required.');
}

// Create QuestionEntity with given Params & Id.
$question = Question::fromParams($keyValuePairs);
$question->setId($questionId);
Expand Down Expand Up @@ -878,6 +894,12 @@ public function newOption(int $formId, int $questionId, array $optionTexts): Dat
throw new OCSBadRequestException('This question is not part ot the given form');
}

// Sections cannot have options
if ($question->getType() === Constants::ANSWER_TYPE_SECTION) {
$this->logger->debug('Sections cannot have options');
throw new OCSBadRequestException('Sections cannot have options');
}

// Retrieve all options sorted by 'order'. Takes the order of the last array-element and adds one.
$options = $this->optionMapper->findByQuestion($questionId);
$lastOption = array_pop($options);
Expand Down Expand Up @@ -959,6 +981,12 @@ public function updateOption(int $formId, int $questionId, int $optionId, array
throw new OCSBadRequestException('The given option id doesn\'t match the question or form.');
}

// Sections cannot have options
if ($question->getType() === Constants::ANSWER_TYPE_SECTION) {
$this->logger->debug('Sections cannot have options');
throw new OCSBadRequestException('Sections cannot have options');
}

// Don't allow empty array
if (sizeof($keyValuePairs) === 0) {
$this->logger->info('Empty keyValuePairs, will not update');
Expand Down Expand Up @@ -1026,6 +1054,12 @@ public function deleteOption(int $formId, int $questionId, int $optionId): DataR
throw new OCSBadRequestException('The given option id doesn\'t match the question or form.');
}

// Sections cannot have options
if ($question->getType() === Constants::ANSWER_TYPE_SECTION) {
$this->logger->debug('Sections cannot have options');
throw new OCSBadRequestException('Sections cannot have options');
}

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

// Reorder the remaining options
Expand Down Expand Up @@ -1081,6 +1115,12 @@ public function reorderOptions(int $formId, int $questionId, array $newOrder) {
throw new OCSBadRequestException('The given question id doesn\'t match the form.');
}

// Sections cannot have options
if ($question->getType() === Constants::ANSWER_TYPE_SECTION) {
$this->logger->debug('Sections cannot have options');
throw new OCSBadRequestException('Sections cannot have options');
}

// Check if array contains duplicates
if (array_unique($newOrder) !== $newOrder) {
$this->logger->debug('The given array contains duplicates');
Expand Down Expand Up @@ -1210,6 +1250,11 @@ public function getSubmissions(int $formId, ?string $query = null, ?int $limit =
return $question;
}, $questions);

// Filter out sections from questions for export
$questions = array_values(array_filter($questions, static function (array $question) {
return $question['type'] !== Constants::ANSWER_TYPE_SECTION;
}));

$response = [
'submissions' => $submissions,
'questions' => $questions,
Expand Down Expand Up @@ -1590,6 +1635,12 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
throw new OCSBadRequestException('Question doesn\'t belong to the given form');
}

// Sections cannot have file uploads
if ($question->getType() === Constants::ANSWER_TYPE_SECTION) {
$this->logger->debug('Sections cannot have file uploads');
throw new OCSBadRequestException('Sections cannot have file uploads');
}

$path = $this->formsService->getTemporaryUploadedFilePath($form, $question);

$response = [];
Expand Down Expand Up @@ -1687,6 +1738,11 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
* @param string[]|array<array{uploadedFileId: string, uploadedFileName: string}> $answerArray
*/
private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void {
// Don't store answers for sections
if ($question['type'] === Constants::ANSWER_TYPE_SECTION) {
return;
}

foreach ($answerArray as $answer) {
$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
Expand Down
2 changes: 1 addition & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
* validationType?: string
* }
*
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"section"
*
* @psalm-type FormsQuestion = array{
* id: int,
Expand Down
6 changes: 6 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
$submissionEntities = array_reverse($submissionEntities);

$questions = $this->questionMapper->findByForm($form->getId());

// Filter out sections from export
$questions = array_filter($questions, function (Question $question) {
return $question->getType() !== Constants::ANSWER_TYPE_SECTION;
});

$defaultTimeZone = $this->config->getSystemValueString('default_timezone', 'UTC');

if (!$this->currentUser) {
Expand Down
Loading
Loading