Skip to content
Open
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');
}

// 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(),
];
}
}
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;
}
}
10 changes: 10 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 Expand Up @@ -484,6 +488,12 @@ public function canDeleteResults(Form $form): bool {
* @return boolean
*/
public function canSubmit(Form $form): bool {
// Check if max submissions limit is reached
$maxSubmissions = $form->getMaxSubmissions();
if ($maxSubmissions !== null && $this->submissionMapper->countSubmissions($form->getId()) >= $maxSubmissions) {
return false;
}

// We cannot control how many time users can submit if public link available
if ($this->hasPublicLink($form)) {
return true;
Expand Down
45 changes: 45 additions & 0 deletions src/components/SidebarTabs/SettingsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@
{{ 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 @@ -181,6 +200,7 @@ import { loadState } from '@nextcloud/initial-state'
import moment from '@nextcloud/moment'
import { directive as ClickOutside } from 'v-click-outside'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
Expand All @@ -193,6 +213,7 @@ import { FormState } from '../../models/Constants.ts'
export default {
components: {
NcButton,
NcInputField,
NcCheckboxRadioSwitch,
NcDateTimePicker,
NcIconSvgWrapper,
Expand Down Expand Up @@ -302,6 +323,19 @@ 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)
},
},

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

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

onMaxSubmissionsValueChange(event) {
const value = parseInt(event.target.value)
if (value > 0) {
this.$emit('update:form-prop', 'maxSubmissions', value)
}
},

onFormClosedChange(isClosed) {
this.$emit(
'update:form-prop',
Expand Down
14 changes: 13 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)"
class="forms-emptycontent"
:name="
form.submissionMessage
Expand All @@ -74,6 +74,15 @@
<p class="submission-message" v-html="submissionMessageHTML" />
</template>
</NcEmptyContent>
<NcEmptyContent
v-else-if="isMaxSubmissionsReached"
class="forms-emptycontent"
:name="t('forms', 'Form is full')"
:description="t('forms', 'This form has reached the maximum number of answers')">
<template #icon>
<NcIconSvgWrapper :svg="IconCheckSvg" size="64" />
</template>
</NcEmptyContent>
<NcEmptyContent
v-else-if="isExpired"
class="forms-emptycontent"
Expand Down Expand Up @@ -359,6 +368,9 @@ export default {
isClosed() {
return this.form.state === FormState.FormClosed
},
isMaxSubmissionsReached() {
return this.form.isMaxSubmissionsReached === true
},

/**
* Checks if the current state is active.
Expand Down