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
6 changes: 6 additions & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,12 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
throw new OCSForbiddenException('Already submitted');
}

// Check if max submissions limit is reached
$maxSubmissions = $form->getMaxSubmissions();
if ($maxSubmissions > 0 && $this->submissionMapper->countSubmissions($formId) >= $maxSubmissions) {
throw new OCSForbiddenException('Maximum number of submissions reached');
}
Comment thread
LAfricain marked this conversation as resolved.

// Insert new submission
$this->submissionMapper->insert($submission);

Expand Down
6 changes: 6 additions & 0 deletions lib/Db/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
* @method string getLockedBy()
* @method void setLockedBy(string|null $value)
* @method int getLockedUntil()
* @method int|null getMaxSubmissions()
* @method void setMaxSubmissions(int|null $value)
* @method void setLockedUntil(int|null $value)
*/
class Form extends Entity {
Expand All @@ -71,6 +73,7 @@ class Form extends Entity {
protected $state;
protected $lockedBy;
protected $lockedUntil;
protected $maxSubmissions;

/**
* Form constructor.
Expand All @@ -86,6 +89,7 @@ public function __construct() {
$this->addType('state', 'integer');
$this->addType('lockedBy', 'string');
$this->addType('lockedUntil', 'integer');
$this->addType('maxSubmissions', 'integer');
}

// JSON-Decoding of access-column.
Expand Down Expand Up @@ -159,6 +163,7 @@ public function setAccess(array $access): void {
* state: 0|1|2,
* lockedBy: ?string,
* lockedUntil: ?int,
* maxSubmissions: ?int,
* }
*/
public function read() {
Expand All @@ -182,6 +187,7 @@ public function read() {
'state' => $this->getState(),
'lockedBy' => $this->getLockedBy(),
'lockedUntil' => $this->getLockedUntil(),
'maxSubmissions' => $this->getMaxSubmissions(),
];
}
}
1 change: 1 addition & 0 deletions lib/FormsMigrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
$form->setSubmitMultiple($formData['submitMultiple']);
$form->setAllowEditSubmissions($formData['allowEditSubmissions']);
$form->setShowExpiration($formData['showExpiration']);
$form->setMaxSubmissions($formData['maxSubmissions'] ?? null);

$this->formMapper->insert($form);

Expand Down
41 changes: 41 additions & 0 deletions lib/Migration/Version050300Date20260303000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Forms\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version050300Date20260303000000 extends SimpleMigrationStep {

/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('forms_v2_forms');

if (!$table->hasColumn('max_submissions')) {
$table->addColumn('max_submissions', Types::INTEGER, [
'notnull' => false,
'default' => null,
'comment' => 'Maximum number of submissions, null means unlimited',
]);
}

return $schema;
}
}
Comment thread
LAfricain marked this conversation as resolved.
3 changes: 3 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
* state: int,
* lockedBy: ?string,
* lockedUntil: ?int,
* maxSubmissions: ?int,
* }
*
* @psalm-type FormsForm = array{
Expand All @@ -125,6 +126,7 @@
* fileId: ?int,
* filePath?: ?string,
* isAnonymous: bool,
* isMaxSubmissionsReached: bool,
* lastUpdated: int,
* submitMultiple: bool,
* allowEditSubmissions: bool,
Expand All @@ -135,6 +137,7 @@
* state: 0|1|2,
* lockedBy: ?string,
* lockedUntil: ?int,
* maxSubmissions: ?int,
* shares: list<FormsShare>,
* submissionCount?: int,
* submissionMessage: ?string,
Expand Down
4 changes: 4 additions & 0 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ public function getForm(Form $form): array {
$result['permissions'] = $this->getPermissions($form);
// Append canSubmit, to be able to show proper EmptyContent on internal view.
$result['canSubmit'] = $this->canSubmit($form);
// Append isMaxSubmissionsReached to show proper message on submit view.
$maxSubmissions = $form->getMaxSubmissions();
$result['isMaxSubmissionsReached'] = $maxSubmissions !== null
&& $this->submissionMapper->countSubmissions($form->getId()) >= $maxSubmissions;

// Append submissionCount if currentUser has permissions to see results
if (in_array(Constants::PERMISSION_RESULTS, $result['permissions'])) {
Expand Down
18 changes: 17 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"fileFormat",
"fileId",
"isAnonymous",
"isMaxSubmissionsReached",
"lastUpdated",
"submitMultiple",
"allowEditSubmissions",
Expand All @@ -116,6 +117,7 @@
"state",
"lockedBy",
"lockedUntil",
"maxSubmissions",
"shares",
"submissionMessage"
],
Expand Down Expand Up @@ -163,6 +165,9 @@
"isAnonymous": {
"type": "boolean"
},
"isMaxSubmissionsReached": {
"type": "boolean"
},
"lastUpdated": {
"type": "integer",
"format": "int64"
Expand Down Expand Up @@ -209,6 +214,11 @@
"format": "int64",
"nullable": true
},
"maxSubmissions": {
"type": "integer",
"format": "int64",
"nullable": true
},
"shares": {
"type": "array",
"items": {
Expand Down Expand Up @@ -307,7 +317,8 @@
"partial",
"state",
"lockedBy",
"lockedUntil"
"lockedUntil",
"maxSubmissions"
],
"properties": {
"id": {
Expand Down Expand Up @@ -348,6 +359,11 @@
"type": "integer",
"format": "int64",
"nullable": true
},
"maxSubmissions": {
"type": "integer",
"format": "int64",
"nullable": true
}
}
},
Expand Down
55 changes: 55 additions & 0 deletions src/components/SidebarTabs/SettingsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,32 @@
{{ t('forms', 'Show expiration date on form') }}
</NcCheckboxRadioSwitch>
</div>
<NcCheckboxRadioSwitch
:model-value="hasMaxSubmissions"
:disabled="formArchived || locked"
type="switch"
@update:model-value="onMaxSubmissionsChange">
{{ t('forms', 'Limit number of responses') }}
</NcCheckboxRadioSwitch>
<div
v-show="hasMaxSubmissions && !formArchived"
class="settings-div--indent">
<NcInputField
v-model="maxSubmissionsValue"
type="number"
:min="1"
:disabled="locked"
:label="t('forms', 'Maximum number of responses')"
@update:model-value="onMaxSubmissionsValueChange" />
<p class="settings-hint">
{{
t(
'forms',
'Form will be closed automatically when the limit is reached.',
)
}}
</p>
</div>
<NcCheckboxRadioSwitch
:model-value="formClosed"
:disabled="formArchived || locked"
Expand Down Expand Up @@ -184,6 +210,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import TransferOwnership from './TransferOwnership.vue'
import svgLockOpen from '../../../img/lock_open.svg?raw'
Expand All @@ -193,6 +220,7 @@ import { FormState } from '../../models/Constants.ts'
export default {
components: {
NcButton,
NcInputField,
NcCheckboxRadioSwitch,
NcDateTimePicker,
NcIconSvgWrapper,
Expand Down Expand Up @@ -302,6 +330,23 @@ export default {
return this.form.state !== FormState.FormActive
},

hasMaxSubmissions() {
return (
this.form.maxSubmissions !== null
&& this.form.maxSubmissions !== undefined
)
},

maxSubmissionsValue: {
get() {
return this.form.maxSubmissions ?? 1
},

set(value) {
this.$emit('update:form-prop', 'maxSubmissions', value)
},
},
Comment thread
LAfricain marked this conversation as resolved.

isExpired() {
return this.form.expires && moment().unix() > this.form.expires
},
Expand Down Expand Up @@ -365,6 +410,16 @@ export default {
)
},

onMaxSubmissionsChange(checked) {
this.$emit('update:form-prop', 'maxSubmissions', checked ? 1 : null)
},

onMaxSubmissionsValueChange(value) {
if (value > 0) {
this.$emit('update:form-prop', 'maxSubmissions', value)
}
},
Comment thread
LAfricain marked this conversation as resolved.

onFormClosedChange(isClosed) {
this.$emit(
'update:form-prop',
Expand Down
20 changes: 19 additions & 1 deletion src/views/Submit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
</template>
</NcEmptyContent>
<NcEmptyContent
v-else-if="success || !form.canSubmit"
v-else-if="success || (!form.canSubmit && !isMaxSubmissionsReached)"
Comment thread
LAfricain marked this conversation as resolved.
class="forms-emptycontent"
:name="
form.submissionMessage
Expand All @@ -74,6 +74,20 @@
<p class="submission-message" v-html="submissionMessageHTML" />
</template>
</NcEmptyContent>
<NcEmptyContent
v-else-if="isMaxSubmissionsReached"
class="forms-emptycontent"
:name="t('forms', 'Limit reached')"
:description="
t(
'forms',
'This form has reached the maximum number of responses',
)
">
<template #icon>
<NcIconSvgWrapper :svg="IconCheckSvg" size="64" />
</template>
</NcEmptyContent>
<NcEmptyContent
v-else-if="isExpired"
class="forms-emptycontent"
Expand Down Expand Up @@ -360,6 +374,10 @@ export default {
return this.form.state === FormState.FormClosed
},

isMaxSubmissionsReached() {
return this.form.isMaxSubmissionsReached === true
},

/**
* Checks if the current state is active.
*
Expand Down
4 changes: 4 additions & 0 deletions tests/Integration/Api/ApiV3Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ public function dataGetNewForm() {
'submissionMessage' => null,
'fileId' => null,
'fileFormat' => null,
'maxSubmissions' => null,
'isMaxSubmissionsReached' => false,
]
]
];
Expand Down Expand Up @@ -525,6 +527,8 @@ public function dataGetFullForm() {
'submissionCount' => 3,
'fileId' => null,
'fileFormat' => null,
'maxSubmissions' => null,
'isMaxSubmissionsReached' => false,
]
]
];
Expand Down
2 changes: 2 additions & 0 deletions tests/Integration/Api/RespectAdminSettingsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ private static function sharedTestForms(): array {
],
'canSubmit' => true,
'submissionCount' => 0,
'maxSubmissions' => null,
'isMaxSubmissionsReached' => false,
],
];
}
Expand Down
1 change: 1 addition & 0 deletions tests/Unit/Controller/ApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ public function dataTestCreateNewForm() {
'allowEditSubmissions' => false,
'lockedBy' => null,
'lockedUntil' => null,
'maxSubmissions' => null,
]]
];
}
Expand Down
3 changes: 2 additions & 1 deletion tests/Unit/FormsMigratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ public function dataExport() {
"state": 0,
"lockedBy": null,
"lockedUntil": null,
"maxSubmissions": null,
"isAnonymous": false,
"submitMultiple": false,
"allowEditSubmissions": false,
Expand Down Expand Up @@ -253,7 +254,7 @@ public function testExport(string $expectedJson) {
public function dataImport() {
return [
'exactlyOneOfEach' => [
'$inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]'
'$inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"maxSubmissions":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]'
]
];
}
Expand Down
4 changes: 4 additions & 0 deletions tests/Unit/Service/FormsServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ public function dataGetForm() {
'allowEditSubmissions' => false,
'lockedBy' => null,
'lockedUntil' => null,
'maxSubmissions' => null,
'isMaxSubmissionsReached' => false,
]]
];
}
Expand Down Expand Up @@ -474,6 +476,8 @@ public function dataGetPublicForm() {
'allowEditSubmissions' => false,
'lockedBy' => null,
'lockedUntil' => null,
'maxSubmissions' => null,
'isMaxSubmissionsReached' => false,
]]
];
}
Expand Down
Loading