зеркало из https://github.com/nextcloud/forms.git
Merge pull request #1659 from nextcloud/feat/custom-submission-message-385
feat: Implement custom submission message
This commit is contained in:
Коммит
ef0bdcd9f6
|
@ -120,6 +120,7 @@ Returns the full-depth object of the requested form (without submissions).
|
|||
"title": "Form 1",
|
||||
"description": "Description Text",
|
||||
"ownerId": "jonas",
|
||||
"submissionMessage": "Thank **you** for submitting the form."
|
||||
"created": 1611240961,
|
||||
"access": {
|
||||
"permitAllUsers": false,
|
||||
|
|
|
@ -1,24 +1,25 @@
|
|||
# Forms Data Structure
|
||||
**State: Forms v3.0.0 - 23.03.2022**
|
||||
**State: Forms v3.3.1 - 08.10.2023**
|
||||
|
||||
This document describes the Object-Structure, that is used within the Forms App and on Forms API v2. It does partially **not** equal the actual database structure behind.
|
||||
|
||||
## Data Structures
|
||||
### Form
|
||||
| Property | Type | Restrictions | Description |
|
||||
|-------------|-----------------|--------------|-------------|
|
||||
| id | Integer | unique | An instance-wide unique id of the form |
|
||||
| hash | 16-char String | unique | An instance-wide unique hash |
|
||||
| title | String | max. 256 ch. | The form title |
|
||||
| Property | Type | Restrictions | Description |
|
||||
|-------------|-----------------|---------------|-------------|
|
||||
| id | Integer | unique | An instance-wide unique id of the form |
|
||||
| hash | 16-char String | unique | An instance-wide unique hash |
|
||||
| title | String | max. 256 ch. | The form title |
|
||||
| description | String | max. 8192 ch. | The Form description |
|
||||
| ownerId | String | | The nextcloud userId of the form owner |
|
||||
| created | unix timestamp | | When the form has been created |
|
||||
| ownerId | String | | The nextcloud userId of the form owner |
|
||||
| submissionMessage | String | max. 2048 ch. | Optional custom message, with Markdown support, to be shown to users when the form is submitted (default is used if set to null) |
|
||||
| created | unix timestamp | | When the form has been created |
|
||||
| access | [Access-Object](#access-object) | | Describing access-settings of the form |
|
||||
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
|
||||
| isAnonymous | Boolean | | If Answers will be stored anonymously |
|
||||
| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form |
|
||||
| showExpiration | Boolean | | If the expiration date will be shown on the form |
|
||||
| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. |
|
||||
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
|
||||
| isAnonymous | Boolean | | If Answers will be stored anonymously |
|
||||
| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form |
|
||||
| showExpiration | Boolean | | If the expiration date will be shown on the form |
|
||||
| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. |
|
||||
| permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form |
|
||||
| questions | Array of [Questions](#question) | | Array of questions belonging to the form |
|
||||
| shares | Array of [Shares](#share) | | Array of shares of the form |
|
||||
|
|
|
@ -46,6 +46,7 @@ class Constants {
|
|||
public const MAX_STRING_LENGTHS = [
|
||||
'formTitle' => 256,
|
||||
'formDescription' => 8192,
|
||||
'submissionMessage' => 2048,
|
||||
'questionText' => 2048,
|
||||
'questionDescription' => 4096,
|
||||
'optionText' => 1024,
|
||||
|
|
|
@ -339,8 +339,10 @@ class ApiController extends OCSController {
|
|||
}
|
||||
|
||||
// Create FormEntity with given Params & Id.
|
||||
$form = Form::fromParams($keyValuePairs);
|
||||
$form->setId($id);
|
||||
foreach ($keyValuePairs as $key => $value) {
|
||||
$method = 'set' . ucfirst($key);
|
||||
$form->$method($value);
|
||||
}
|
||||
|
||||
// Update changed Columns in Db.
|
||||
$this->formMapper->update($form);
|
||||
|
|
|
@ -52,6 +52,8 @@ use OCP\AppFramework\Db\Entity;
|
|||
* @method void setShowExpiration(bool $value)
|
||||
* @method integer getLastUpdated()
|
||||
* @method void setLastUpdated(integer $value)
|
||||
* @method ?string getSubmissionMessage()
|
||||
* @method void setSubmissionMessage(?string $value)
|
||||
*/
|
||||
class Form extends Entity {
|
||||
protected $hash;
|
||||
|
@ -64,6 +66,7 @@ class Form extends Entity {
|
|||
protected $isAnonymous;
|
||||
protected $submitMultiple;
|
||||
protected $showExpiration;
|
||||
protected $submissionMessage;
|
||||
protected $lastUpdated;
|
||||
|
||||
/**
|
||||
|
@ -102,7 +105,8 @@ class Form extends Entity {
|
|||
'isAnonymous' => (bool)$this->getIsAnonymous(),
|
||||
'submitMultiple' => (bool)$this->getSubmitMultiple(),
|
||||
'showExpiration' => (bool)$this->getShowExpiration(),
|
||||
'lastUpdated' => (int)$this->getLastUpdated()
|
||||
'lastUpdated' => (int)$this->getLastUpdated(),
|
||||
'submissionMessage' => $this->getSubmissionMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2023 Ferdinand Thiessen <rpm@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <rpm@fthiessen.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\Forms\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version030400Date20230628011500 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('submission_message')) {
|
||||
$table->addColumn('submission_message', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'default' => null,
|
||||
'length' => 2048,
|
||||
'comment' => 'custom thank you message',
|
||||
]);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -59,6 +59,35 @@
|
|||
{{ t('forms', 'Show expiration date on form') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<NcCheckboxRadioSwitch :checked="hasCustomSubmissionMessage"
|
||||
type="switch"
|
||||
@update:checked="onUpdateHasCustomSubmissionMessage">
|
||||
{{ t('forms', 'Custom submission message') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<div v-show="hasCustomSubmissionMessage"
|
||||
class="settings-div--indent submission-message"
|
||||
:tabindex="editMessage ? undefined : '0'"
|
||||
@focus="editMessage = true">
|
||||
<textarea v-if="editMessage || !form.submissionMessage"
|
||||
v-click-outside="() => { editMessage = false }"
|
||||
aria-describedby="forms-submission-message-description"
|
||||
:aria-label="t('forms', 'Custom submission message')"
|
||||
:value="form.submissionMessage"
|
||||
:maxlength="maxStringLengths.submissionMessage"
|
||||
:placeholder="t('forms', 'Message to show after a user submitted the form (formatting using Markdown is supported)')"
|
||||
class="submission-message__input"
|
||||
@blur="editMessage = false"
|
||||
@change="onSubmissionMessageChange" />
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div v-else
|
||||
:aria-label="t('forms', 'Custom submission message')"
|
||||
class="submission-message__output"
|
||||
v-html="submissionMessageHTML" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<div id="forms-submission-message-description" class="submission-message__description">
|
||||
{{ t('forms', 'Message to show after a user submitted the form. Please note that the message will not be translated!') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -68,14 +97,23 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadi
|
|||
import NcDatetimePicker from '@nextcloud/vue/dist/Components/NcDatetimePicker.js'
|
||||
import ShareTypes from '../../mixins/ShareTypes.js'
|
||||
|
||||
import { directive as ClickOutside } from 'v-click-outside'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NcCheckboxRadioSwitch,
|
||||
NcDatetimePicker,
|
||||
},
|
||||
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
|
||||
mixins: [ShareTypes],
|
||||
|
||||
inject: ['$markdownit'],
|
||||
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
|
@ -89,10 +127,20 @@ export default {
|
|||
stringify: this.stringifyDate,
|
||||
parse: this.parseTimestampToDate,
|
||||
},
|
||||
maxStringLengths: loadState('forms', 'maxStringLengths'),
|
||||
/** If custom submission message is shown as input or rendered markdown */
|
||||
editMessage: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* If the form has a custom submission message or the user wants to add one (settings switch)
|
||||
*/
|
||||
hasCustomSubmissionMessage() {
|
||||
return this.form?.submissionMessage !== undefined && this.form?.submissionMessage !== null
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit Multiple is disabled, if it cannot be controlled.
|
||||
*/
|
||||
|
@ -125,6 +173,12 @@ export default {
|
|||
expirationDate() {
|
||||
return moment(this.form.expires, 'X').toDate()
|
||||
},
|
||||
/**
|
||||
* The submission message rendered as HTML
|
||||
*/
|
||||
submissionMessageHTML() {
|
||||
return this.$markdownit.render(this.form.submissionMessage || '')
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
@ -159,6 +213,22 @@ export default {
|
|||
this.$emit('update:formProp', 'expires', parseInt(moment(datetime).format('X')))
|
||||
},
|
||||
|
||||
onSubmissionMessageChange({ target }) {
|
||||
this.$emit('update:formProp', 'submissionMessage', target.value)
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable or disable the whole custom submission message
|
||||
* Disabled means the value is set to null.
|
||||
*/
|
||||
onUpdateHasCustomSubmissionMessage() {
|
||||
if (this.hasCustomSubmissionMessage) {
|
||||
this.$emit('update:formProp', 'submissionMessage', null)
|
||||
} else {
|
||||
this.$emit('update:formProp', 'submissionMessage', '')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Datepicker timestamp to string
|
||||
*
|
||||
|
@ -215,4 +285,32 @@ export default {
|
|||
.settings-div--indent {
|
||||
margin-inline-start: 40px;
|
||||
}
|
||||
|
||||
.submission-message {
|
||||
&__description {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__input, &__output {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
&__output {
|
||||
@import '../../scssmixins/markdownOutput';
|
||||
|
||||
padding: 12px;
|
||||
margin-block: 3px;
|
||||
border: 2px solid var(--color-border-maxcontrast);
|
||||
border-radius: var(--border-radius-large);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
@include markdown-output;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
@mixin markdown-output {
|
||||
overflow-wrap: break-word;
|
||||
|
||||
::v-deep {
|
||||
:deep() {
|
||||
>:not(:first-child) {
|
||||
margin-block-start: 1.5em;
|
||||
}
|
||||
|
@ -37,6 +37,11 @@
|
|||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary-element);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-inline-start: 1em;
|
||||
border-inline-start: 4px solid var(--color-primary-element);
|
||||
|
|
|
@ -64,10 +64,15 @@
|
|||
</template>
|
||||
</NcEmptyContent>
|
||||
<NcEmptyContent v-else-if="success || !form.canSubmit"
|
||||
:title="t('forms', 'Thank you for completing the form!')">
|
||||
:title="t('forms', 'Thank you for completing the form!')"
|
||||
:description="form.submissionMessage">
|
||||
<template #icon>
|
||||
<IconCheck :size="64" />
|
||||
</template>
|
||||
<template v-if="submissionMessageHTML" #description>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p class="submission-message" v-html="submissionMessageHTML" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<NcEmptyContent v-else-if="isExpired"
|
||||
:title="t('forms', 'Form expired')"
|
||||
|
@ -237,6 +242,16 @@ export default {
|
|||
return message
|
||||
},
|
||||
|
||||
/**
|
||||
* Rendered HTML of the custom submission message
|
||||
*/
|
||||
submissionMessageHTML() {
|
||||
if (this.form.submissionMessage && (this.success || !this.form.canSubmit)) {
|
||||
return this.markdownit.render(this.form.submissionMessage)
|
||||
}
|
||||
return ''
|
||||
},
|
||||
|
||||
expirationMessage() {
|
||||
const relativeDate = moment(this.form.expires, 'X').fromNow()
|
||||
if (this.isExpired) {
|
||||
|
@ -424,6 +439,11 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
.submission-message {
|
||||
@include markdown-output;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
form {
|
||||
.question {
|
||||
// Less padding needed as submit view does not have drag handles
|
||||
|
|
|
@ -64,6 +64,7 @@ class ApiV2Test extends TestCase {
|
|||
'submit_multiple' => false,
|
||||
'show_expiration' => false,
|
||||
'last_updated' => 123456789,
|
||||
'submission_message' => 'Back to website',
|
||||
'questions' => [
|
||||
[
|
||||
'type' => 'short',
|
||||
|
@ -166,6 +167,7 @@ class ApiV2Test extends TestCase {
|
|||
'submit_multiple' => false,
|
||||
'show_expiration' => false,
|
||||
'last_updated' => 123456789,
|
||||
'submission_message' => '',
|
||||
'questions' => [
|
||||
[
|
||||
'type' => 'short',
|
||||
|
@ -214,6 +216,7 @@ class ApiV2Test extends TestCase {
|
|||
'submit_multiple' => $qb->createNamedParameter($form['submit_multiple'], IQueryBuilder::PARAM_BOOL),
|
||||
'show_expiration' => $qb->createNamedParameter($form['show_expiration'], IQueryBuilder::PARAM_BOOL),
|
||||
'last_updated' => $qb->createNamedParameter($form['last_updated'], IQueryBuilder::PARAM_INT),
|
||||
'submission_message' => $qb->createNamedParameter($form['submission_message'], IQueryBuilder::PARAM_STR),
|
||||
]);
|
||||
$qb->executeStatement();
|
||||
$formId = $qb->getLastInsertId();
|
||||
|
@ -471,6 +474,7 @@ class ApiV2Test extends TestCase {
|
|||
'questions' => [],
|
||||
'shares' => [],
|
||||
'submissionCount' => 0,
|
||||
'submissionMessage' => ''
|
||||
]
|
||||
]
|
||||
];
|
||||
|
@ -523,6 +527,7 @@ class ApiV2Test extends TestCase {
|
|||
'lastUpdated' => 123456789,
|
||||
'canSubmit' => true,
|
||||
'permissions' => Constants::PERMISSION_ALL,
|
||||
'submissionMessage' => 'Back to website',
|
||||
'questions' => [
|
||||
[
|
||||
'type' => 'short',
|
||||
|
|
|
@ -354,7 +354,8 @@ class ApiControllerTest extends TestCase {
|
|||
'isAnonymous' => false,
|
||||
'submitMultiple' => false,
|
||||
'showExpiration' => false,
|
||||
'lastUpdated' => 123456789
|
||||
'lastUpdated' => 123456789,
|
||||
'submissionMessage' => '',
|
||||
]]
|
||||
];
|
||||
}
|
||||
|
|
|
@ -114,7 +114,7 @@ class FormsMigratorTest extends TestCase {
|
|||
public function dataExport() {
|
||||
return [
|
||||
'exactlyOneOfEach' => [
|
||||
'expectedJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"isAnonymous":false,"submitMultiple":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"}]}]}]'
|
||||
'expectedJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"isAnonymous":false,"submitMultiple":false,"showExpiration":false,"lastUpdated":123456789,"submissionMessage":"Back to website","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"}]}]}]'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
@ -151,6 +151,7 @@ class FormsMigratorTest extends TestCase {
|
|||
$form->setSubmitMultiple(false);
|
||||
$form->setShowExpiration(false);
|
||||
$form->setLastUpdated(123456789);
|
||||
$form->setSubmissionMessage('Back to website');
|
||||
|
||||
$this->formsService->expects($this->once())
|
||||
->method('getQuestions')
|
||||
|
|
|
@ -212,6 +212,7 @@ class FormsServiceTest extends TestCase {
|
|||
'displayName' => 'Some User'
|
||||
]
|
||||
],
|
||||
'submissionMessage' => '',
|
||||
'permissions' => Constants::PERMISSION_ALL
|
||||
]]
|
||||
];
|
||||
|
@ -417,7 +418,8 @@ class FormsServiceTest extends TestCase {
|
|||
'questions' => [],
|
||||
'permissions' => [
|
||||
'submit'
|
||||
]
|
||||
],
|
||||
'submissionMessage' => '',
|
||||
]]
|
||||
];
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче